Files
frontend/src/external_app/external_messaging.ts
Timothy f03cd9c239 Add Add entity to feature for external_app (#26346)
* Add Add entity to feature for external_app

* Update icon from plus to plusboxmultiple

* Apply suggestion on the name

* Add missing shouldHandleRequestSelectedEvent that caused duplicate

* WIP

* Rework the logic to match the agreed design

* Rename property

* Apply PR comments

* Apply prettier

* Merge MessageWithAnswer

* Apply PR comments
2025-11-06 08:25:05 +00:00

460 lines
11 KiB
TypeScript

import type { NavigateOptions } from "../common/navigate";
import type { AutomationConfig } from "../data/automation";
const CALLBACK_EXTERNAL_BUS = "externalBus";
interface CommandInFlight {
resolve: (data: any) => void;
reject: (err: EMError) => void;
}
export interface EMMessage {
id?: number;
type: string;
}
interface EMError {
code: string;
message: string;
}
interface EMMessageResultSuccess {
id: number;
type: "result";
success: true;
result: unknown;
}
interface EMMessageResultError {
id: number;
type: "result";
success: false;
error: EMError;
}
interface EMOutgoingMessageConfigGet extends EMMessage {
type: "config/get";
}
interface EMOutgoingMessageEntityAddToGetActions extends EMMessage {
type: "entity/add_to/get_actions";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageBarCodeScan extends EMMessage {
type: "bar_code/scan";
payload: {
title: string;
description: string;
alternative_option_label?: string;
};
}
interface EMOutgoingMessageBarCodeClose extends EMMessage {
type: "bar_code/close";
}
interface EMOutgoingMessageBarCodeNotify extends EMMessage {
type: "bar_code/notify";
payload: {
message: string;
};
}
interface EMOutgoingMessageMatterCommission extends EMMessage {
type: "matter/commission";
payload?: {
mac_extended_address: string | null;
extended_pan_id: string | null;
border_agent_id: string | null;
active_operational_dataset: string | null;
};
}
interface EMOutgoingMessageImportThreadCredentials extends EMMessage {
type: "thread/import_credentials";
}
interface EMOutgoingMessageWithAnswer {
"config/get": {
request: EMOutgoingMessageConfigGet;
response: ExternalConfig;
};
"entity/add_to/get_actions": {
request: EMOutgoingMessageEntityAddToGetActions;
response: ExternalEntityAddToActions;
};
}
interface EMOutgoingMessageExoplayerPlayHLS extends EMMessage {
type: "exoplayer/play_hls";
payload: {
url: string;
muted: boolean;
};
}
interface EMOutgoingMessageExoplayerResize extends EMMessage {
type: "exoplayer/resize";
payload: {
left: number;
top: number;
right: number;
bottom: number;
};
}
interface EMOutgoingMessageExoplayerStop extends EMMessage {
type: "exoplayer/stop";
}
interface EMOutgoingMessageThemeUpdate extends EMMessage {
type: "theme-update";
}
interface EMOutgoingMessageHaptic extends EMMessage {
type: "haptic";
payload: { hapticType: string };
}
interface EMOutgoingMessageConnectionStatus extends EMMessage {
type: "connection-status";
payload: { event: string };
}
interface EMOutgoingMessageAppConfiguration extends EMMessage {
type: "config_screen/show";
}
interface EMOutgoingMessageTagWrite extends EMMessage {
type: "tag/write";
payload: {
name: string | null;
tag: string;
};
}
interface EMOutgoingMessageSidebarShow extends EMMessage {
type: "sidebar/show";
}
interface EMOutgoingMessageAssistShow extends EMMessage {
type: "assist/show";
payload?: {
pipeline_id: "preferred" | "last_used" | string;
start_listening: boolean;
};
}
interface EMOutgoingMessageImprovScan extends EMMessage {
type: "improv/scan";
}
interface EMOutgoingMessageImprovConfigureDevice extends EMMessage {
type: "improv/configure_device";
payload: {
name: string;
};
}
interface EMOutgoingMessageThreadStoreInPlatformKeychain extends EMMessage {
type: "thread/store_in_platform_keychain";
payload: {
mac_extended_address: string | null;
border_agent_id: string | null;
active_operational_dataset: string;
extended_pan_id: string;
};
}
interface EMOutgoingMessageAddEntityTo extends EMMessage {
type: "entity/add_to";
payload: {
entity_id: string;
app_payload: string; // Opaque string received from get_actions
};
}
type EMOutgoingMessageWithoutAnswer =
| EMMessageResultError
| EMMessageResultSuccess
| EMOutgoingMessageAppConfiguration
| EMOutgoingMessageAssistShow
| EMOutgoingMessageBarCodeClose
| EMOutgoingMessageBarCodeNotify
| EMOutgoingMessageBarCodeScan
| EMOutgoingMessageConnectionStatus
| EMOutgoingMessageExoplayerPlayHLS
| EMOutgoingMessageExoplayerResize
| EMOutgoingMessageExoplayerStop
| EMOutgoingMessageHaptic
| EMOutgoingMessageImportThreadCredentials
| EMOutgoingMessageMatterCommission
| EMOutgoingMessageSidebarShow
| EMOutgoingMessageTagWrite
| EMOutgoingMessageThemeUpdate
| EMOutgoingMessageThreadStoreInPlatformKeychain
| EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo;
export interface EMIncomingMessageRestart {
id: number;
type: "command";
command: "restart";
}
export interface EMIncomingMessageNavigate {
id: number;
type: "command";
command: "navigate";
payload: {
path: string;
options?: NavigateOptions;
};
}
export interface EMIncomingMessageShowNotifications {
id: number;
type: "command";
command: "notifications/show";
}
export interface EMIncomingMessageToggleSidebar {
id: number;
type: "command";
command: "sidebar/toggle";
}
export interface EMIncomingMessageShowSidebar {
id: number;
type: "command";
command: "sidebar/show";
}
export interface EMIncomingMessageShowAutomationEditor {
id: number;
type: "command";
command: "automation/editor/show";
payload?: {
config?: Partial<AutomationConfig>;
};
}
export interface EMIncomingMessageBarCodeScanResult {
id: number;
type: "command";
command: "bar_code/scan_result";
payload: {
// A string decoded from the barcode data.
rawValue: string;
// https://developer.mozilla.org/en-US/docs/Web/API/Barcode_Detection_API#supported_barcode_formats
format:
| "aztec"
| "code_128"
| "code_39"
| "code_93"
| "codabar"
| "data_matrix"
| "ean_13"
| "ean_8"
| "itf"
| "pdf417"
| "qr_code"
| "upc_a"
| "upc_e"
| "unknown";
};
}
export interface EMIncomingMessageBarCodeScanAborted {
id: number;
type: "command";
command: "bar_code/aborted";
payload: {
reason: "canceled" | "alternative_options";
};
}
export interface ImprovDiscoveredDevice {
name: string;
}
export interface EMIncomingMessageImprovDeviceDiscovered extends EMMessage {
id: number;
type: "command";
command: "improv/discovered_device";
payload: ImprovDiscoveredDevice;
}
export interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage {
id: number;
type: "command";
command: "improv/device_setup_done";
}
export type EMIncomingMessageCommands =
| EMIncomingMessageRestart
| EMIncomingMessageNavigate
| EMIncomingMessageShowNotifications
| EMIncomingMessageToggleSidebar
| EMIncomingMessageShowSidebar
| EMIncomingMessageShowAutomationEditor
| EMIncomingMessageBarCodeScanResult
| EMIncomingMessageBarCodeScanAborted
| EMIncomingMessageImprovDeviceDiscovered
| EMIncomingMessageImprovDeviceSetupDone;
type EMIncomingMessage =
| EMMessageResultSuccess
| EMMessageResultError
| EMIncomingMessageCommands;
type EMIncomingMessageHandler = (msg: EMIncomingMessageCommands) => boolean;
export interface ExternalConfig {
hasSettingsScreen?: boolean;
hasSidebar?: boolean;
canWriteTag?: boolean;
hasExoPlayer?: boolean;
canCommissionMatter?: boolean;
canImportThreadCredentials?: boolean;
canTransferThreadCredentialsToKeychain?: boolean;
hasAssist?: boolean;
hasBarCodeScanner?: number;
canSetupImprov?: boolean;
downloadFileSupported?: boolean;
appVersion?: string;
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
}
export interface ExternalEntityAddToAction {
enabled: boolean;
name: string; // Translated name of the action to be displayed in the UI
details?: string; // Optional translated details of the action to be displayed in the UI
mdi_icon: string; // MDI icon name to be displayed in the UI (e.g., "mdi:car")
app_payload: string; // Opaque string to be sent back when the action is selected
}
export interface ExternalEntityAddToActions {
actions: ExternalEntityAddToAction[];
}
export class ExternalMessaging {
public config!: ExternalConfig;
public commands: Record<number, CommandInFlight> = {};
public msgId = 0;
private _commandHandler?: EMIncomingMessageHandler;
public async attach() {
window[CALLBACK_EXTERNAL_BUS] = (msg) => this.receiveMessage(msg);
window.addEventListener("connection-status", (ev) =>
this.fireMessage({
type: "connection-status",
payload: { event: ev.detail },
})
);
this.config = await this.sendMessage<"config/get">({
type: "config/get",
});
}
public addCommandHandler(handler: EMIncomingMessageHandler) {
this._commandHandler = handler;
}
/**
* Send message to external app that expects a response.
* @param msg message to send
*/
public sendMessage<T extends keyof EMOutgoingMessageWithAnswer>(
msg: EMOutgoingMessageWithAnswer[T]["request"]
): Promise<EMOutgoingMessageWithAnswer[T]["response"]> {
const msgId = ++this.msgId;
msg.id = msgId;
this._sendExternal(msg);
return new Promise<EMOutgoingMessageWithAnswer[T]["response"]>(
(resolve, reject) => {
this.commands[msgId] = { resolve, reject };
}
);
}
/**
* Send message to external app without expecting a response.
* @param msg message to send
*/
public fireMessage(msg: EMOutgoingMessageWithoutAnswer) {
if (!msg.id) {
msg.id = ++this.msgId;
}
this._sendExternal(msg);
}
public receiveMessage(msg: EMIncomingMessage) {
if (__DEV__) {
// eslint-disable-next-line no-console
console.log("Receiving message from external app", msg);
}
if (msg.type === "command") {
if (!this._commandHandler || !this._commandHandler(msg)) {
let code: string;
let message: string;
if (this._commandHandler) {
code = "not_ready";
message = "Command handler not ready";
} else {
code = "unknown_command";
message = `Unknown command ${msg.command}`;
}
// eslint-disable-next-line no-console
console.warn(message, msg);
this.fireMessage({
id: msg.id,
type: "result",
success: false,
error: {
code,
message,
},
});
}
return;
}
const pendingCmd = this.commands[msg.id];
if (!pendingCmd) {
// eslint-disable-next-line no-console
console.warn(`Received unknown msg ID`, msg.id);
return;
}
if (msg.type === "result") {
if (msg.success) {
pendingCmd.resolve(msg.result);
} else {
pendingCmd.reject(msg.error);
}
}
}
protected _sendExternal(msg: EMMessage) {
if (__DEV__) {
// eslint-disable-next-line no-console
console.log("Sending message to external app", msg);
}
if (window.externalApp) {
window.externalApp.externalBus(JSON.stringify(msg));
} else {
window.webkit!.messageHandlers.externalBus.postMessage(msg);
}
}
}