mirror of
https://github.com/home-assistant/iOS.git
synced 2026-04-12 15:26:45 -05:00
441 lines
15 KiB
Swift
441 lines
15 KiB
Swift
import Foundation
|
|
import GRDB
|
|
import HAKit
|
|
import PromiseKit
|
|
|
|
/// Object that represents iOS item that can be displayed in Watch, Widgets, CarPlay and perform different action types
|
|
public struct MagicItem: Codable, Equatable, Hashable {
|
|
/// Identity-based equality for use in sets/dictionaries and caching.
|
|
/// Compares only stable identity fields, not mutable content.
|
|
public static func == (lhs: MagicItem, rhs: MagicItem) -> Bool {
|
|
lhs.id == rhs.id
|
|
&& lhs.serverId == rhs.serverId
|
|
&& lhs.type == rhs.type
|
|
}
|
|
|
|
/// Identity-based hashing consistent with `==`.
|
|
public func hash(into hasher: inout Hasher) {
|
|
hasher.combine(id)
|
|
hasher.combine(serverId)
|
|
hasher.combine(type)
|
|
}
|
|
|
|
/// Content-based equality for UI/change detection.
|
|
/// Unlike `==`, this includes mutable fields.
|
|
public func contentEquals(_ other: MagicItem) -> Bool {
|
|
id == other.id
|
|
&& serverId == other.serverId
|
|
&& type == other.type
|
|
&& customization == other.customization
|
|
&& action == other.action
|
|
&& displayText == other.displayText
|
|
&& items == other.items
|
|
}
|
|
|
|
/// Id match it's type Id, e.g. "script.open_gate"
|
|
public let id: String
|
|
public var serverId: String
|
|
public let type: ItemType
|
|
public var customization: Customization?
|
|
public var action: ItemAction?
|
|
public var displayText: String?
|
|
public var items: [MagicItem]? /// Only for folder type, represents items inside the folder
|
|
|
|
/// Server unique ID - e.g. "EB1364-script.open_gate"
|
|
public var serverUniqueId: String {
|
|
"\(serverId)-\(id)"
|
|
}
|
|
|
|
/// A hash value that includes mutable content fields, for use as a SwiftUI animation/change detection value.
|
|
public var contentHash: Int {
|
|
var hasher = Hasher()
|
|
hasher.combine(id)
|
|
hasher.combine(serverId)
|
|
hasher.combine(type)
|
|
hasher.combine(customization)
|
|
hasher.combine(action)
|
|
hasher.combine(displayText)
|
|
hasher.combine(items?.map(\.contentHash))
|
|
return hasher.finalize()
|
|
}
|
|
|
|
/// Domain retrieved from id when item is entity else nil
|
|
public var domain: Domain? {
|
|
if let domainString = id.split(separator: ".").first, let domain = Domain(rawValue: String(domainString)) {
|
|
return domain
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public init(
|
|
id: String,
|
|
serverId: String,
|
|
type: ItemType,
|
|
customization: Customization? = .init(),
|
|
action: ItemAction? = .default,
|
|
displayText: String? = nil,
|
|
items: [MagicItem]? = nil
|
|
) {
|
|
self.id = id
|
|
self.serverId = serverId
|
|
self.type = type
|
|
self.customization = customization
|
|
self.action = action
|
|
self.displayText = displayText
|
|
self.items = items
|
|
}
|
|
|
|
public enum ItemType: String, Codable {
|
|
/// aka iOS legacy Action
|
|
case action
|
|
case script
|
|
case scene
|
|
case entity
|
|
case folder
|
|
}
|
|
|
|
public struct Customization: Codable, Equatable, Hashable {
|
|
public var iconColor: String?
|
|
public var textColor: String?
|
|
public var backgroundColor: String?
|
|
/// If true, execution will request confirmation before running
|
|
public var requiresConfirmation: Bool
|
|
/// Override icon, MaterialDesignIcons name
|
|
public var icon: String?
|
|
/// True only when the user explicitly picked a custom icon via the icon picker
|
|
public var iconIsCustomized: Bool?
|
|
|
|
public var useCustomColors: Bool {
|
|
textColor != nil || backgroundColor != nil
|
|
}
|
|
|
|
public init(
|
|
iconColor: String? = nil,
|
|
textColor: String? = nil,
|
|
backgroundColor: String? = nil,
|
|
requiresConfirmation: Bool = false,
|
|
icon: String? = nil,
|
|
iconIsCustomized: Bool = false
|
|
) {
|
|
self.iconColor = iconColor
|
|
self.textColor = textColor
|
|
self.backgroundColor = backgroundColor
|
|
self.requiresConfirmation = requiresConfirmation
|
|
self.icon = icon
|
|
self.iconIsCustomized = iconIsCustomized
|
|
}
|
|
}
|
|
|
|
public struct Info: WatchCodable, Equatable {
|
|
/// Server unique ID - "\(serverId)-(entityId)"
|
|
public let id: String
|
|
public let name: String
|
|
public let iconName: String
|
|
public let customization: Customization?
|
|
|
|
public init(id: String, name: String, iconName: String, customization: Customization? = nil) {
|
|
self.id = id
|
|
self.name = name
|
|
self.iconName = iconName
|
|
self.customization = customization
|
|
}
|
|
}
|
|
|
|
/// Icon for given magic item type
|
|
public func icon(info: Info) -> MaterialDesignIcons {
|
|
var icon: MaterialDesignIcons
|
|
if let icon = customization?.icon {
|
|
return MaterialDesignIcons(named: icon, fallback: .dotsGridIcon)
|
|
} else {
|
|
switch type {
|
|
case .action, .scene:
|
|
icon = MaterialDesignIcons(named: info.iconName, fallback: .scriptTextOutlineIcon)
|
|
case .script, .entity:
|
|
icon = MaterialDesignIcons(
|
|
serversideValueNamed: info.iconName,
|
|
fallback: .dotsGridIcon
|
|
)
|
|
case .folder:
|
|
icon = .folderIcon
|
|
}
|
|
}
|
|
|
|
return icon
|
|
}
|
|
|
|
/// Name to be visible when rendegin item, priority: displayText -> info.name
|
|
public func name(info: Info) -> String {
|
|
displayText ?? info.name
|
|
}
|
|
|
|
public var widgetInteractionType: WidgetInteractionType {
|
|
let magicItem = self
|
|
guard let domain = magicItem.domain else { return .appIntent(.refresh) }
|
|
|
|
var interactionType: WidgetInteractionType = .appIntent(.refresh)
|
|
|
|
if let magicItemAction = magicItem.action, magicItemAction != .default {
|
|
switch magicItemAction {
|
|
case .default:
|
|
// This block of code should not be reached, default should not be handled here
|
|
// Returning something to avoid compiler error
|
|
interactionType = .appIntent(.refresh)
|
|
case .moreInfoDialog:
|
|
interactionType = navigateIntent(url: AppConstants.openEntityDeeplinkURL(
|
|
entityId: magicItem.id,
|
|
serverId: magicItem.serverId
|
|
))
|
|
case .nothing:
|
|
interactionType = .appIntent(.refresh)
|
|
case let .navigate(path):
|
|
interactionType = navigateIntent(path: path)
|
|
case let .runScript(serverId, scriptId):
|
|
interactionType = .appIntent(.activate(
|
|
entityId: scriptId,
|
|
domain: Domain.script.rawValue,
|
|
serverId: serverId
|
|
))
|
|
case let .assist(serverId, pipelineId, startListening):
|
|
interactionType = assistIntent(
|
|
serverId: serverId,
|
|
pipelineId: pipelineId,
|
|
startListening: startListening
|
|
)
|
|
}
|
|
} else {
|
|
switch domain {
|
|
case .button, .inputButton:
|
|
interactionType = .appIntent(.press(
|
|
entityId: magicItem.id,
|
|
domain: domain.rawValue,
|
|
serverId: magicItem.serverId
|
|
))
|
|
case .cover, .inputBoolean, .light, .switch:
|
|
interactionType = .appIntent(.toggle(
|
|
entityId: magicItem.id,
|
|
domain: domain.rawValue,
|
|
serverId: magicItem.serverId
|
|
))
|
|
case .lock:
|
|
interactionType = navigateIntent(url: AppConstants.openEntityDeeplinkURL(
|
|
entityId: magicItem.id,
|
|
serverId: magicItem.serverId
|
|
))
|
|
case .climate:
|
|
interactionType = navigateIntent(url: AppConstants.openEntityDeeplinkURL(
|
|
entityId: magicItem.id,
|
|
serverId: magicItem.serverId
|
|
))
|
|
case .scene, .script:
|
|
interactionType = .appIntent(.activate(
|
|
entityId: magicItem.id,
|
|
domain: domain.rawValue,
|
|
serverId: magicItem.serverId
|
|
))
|
|
default:
|
|
interactionType = navigateIntent(url: AppConstants.openEntityDeeplinkURL(
|
|
entityId: magicItem.id,
|
|
serverId: magicItem.serverId
|
|
))
|
|
}
|
|
}
|
|
|
|
return interactionType
|
|
}
|
|
|
|
private func navigateIntent(path: String) -> WidgetInteractionType {
|
|
let magicItem = self
|
|
var path = path
|
|
if path.hasPrefix("/") {
|
|
path.removeFirst()
|
|
}
|
|
if let url = AppConstants.navigateDeeplinkURL(
|
|
path: path,
|
|
serverId: magicItem.serverId,
|
|
avoidUnnecessaryReload: true
|
|
) {
|
|
return .widgetURL(url)
|
|
} else {
|
|
return .appIntent(.refresh)
|
|
}
|
|
}
|
|
|
|
private func navigateIntent(url: URL?) -> WidgetInteractionType {
|
|
guard let url else {
|
|
return .appIntent(.refresh)
|
|
}
|
|
return .widgetURL(url)
|
|
}
|
|
|
|
private func assistIntent(serverId: String, pipelineId: String, startListening: Bool) -> WidgetInteractionType {
|
|
if let url = AppConstants.assistDeeplinkURL(
|
|
serverId: serverId,
|
|
pipelineId: pipelineId,
|
|
startListening: startListening
|
|
) {
|
|
return .widgetURL(url)
|
|
} else {
|
|
return .appIntent(.refresh)
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum MagicItemError: Error {
|
|
case unknownDomain
|
|
}
|
|
|
|
public enum ItemAction: Codable, CaseIterable, Equatable, Hashable {
|
|
public static var allCases: [ItemAction] = [
|
|
.default,
|
|
.moreInfoDialog,
|
|
.navigate(""),
|
|
.runScript("", ""),
|
|
.assist("", "", false),
|
|
.nothing,
|
|
]
|
|
|
|
case `default`
|
|
case moreInfoDialog
|
|
case navigate(_ navigationPath: String)
|
|
case runScript(_ serverId: String, _ scriptId: String)
|
|
case assist(_ serverId: String, _ pipelineId: String, _ startListening: Bool)
|
|
case nothing
|
|
|
|
public var id: String {
|
|
switch self {
|
|
case .default:
|
|
return "default"
|
|
case .moreInfoDialog:
|
|
return "moreInfoDialog"
|
|
case .navigate:
|
|
return "navigate"
|
|
case .runScript:
|
|
return "runScript"
|
|
case .assist:
|
|
return "assist"
|
|
case .nothing:
|
|
return "nothing"
|
|
}
|
|
}
|
|
|
|
public var name: String {
|
|
switch self {
|
|
case .default:
|
|
return L10n.Widgets.Action.Name.default
|
|
case .moreInfoDialog:
|
|
return L10n.Widgets.Action.Name.moreInfoDialog
|
|
case .navigate:
|
|
return L10n.Widgets.Action.Name.navigate
|
|
case .runScript:
|
|
return L10n.Widgets.Action.Name.runScript
|
|
case .assist:
|
|
return L10n.Widgets.Action.Name.assist
|
|
case .nothing:
|
|
return L10n.Widgets.Action.Name.nothing
|
|
}
|
|
}
|
|
}
|
|
|
|
public extension MagicItem {
|
|
// currentItemState is used only for lock domain since it can't be toggled
|
|
func execute(
|
|
on server: Server,
|
|
source: AppTriggerSource,
|
|
currentItemState: String = "",
|
|
completion: @escaping (Bool) -> Void
|
|
) {
|
|
do {
|
|
if let request: Promise<Void> = try {
|
|
switch type {
|
|
case .script:
|
|
let domain = Domain.script.rawValue
|
|
let service = id.replacingOccurrences(of: "\(domain).", with: "")
|
|
return Current.api(for: server)?.CallService(
|
|
domain: domain,
|
|
service: service,
|
|
serviceData: [:],
|
|
triggerSource: source,
|
|
shouldLog: true
|
|
)
|
|
case .action:
|
|
return Current.api(for: server)?
|
|
.HandleAction(actionID: id, source: source)
|
|
case .scene:
|
|
let domain = Domain.scene.rawValue
|
|
return Current.api(for: server)?.CallService(
|
|
domain: domain,
|
|
service: Service.turnOn.rawValue,
|
|
serviceData: [
|
|
"entity_id": id,
|
|
],
|
|
triggerSource: source,
|
|
shouldLog: true
|
|
)
|
|
case .entity:
|
|
guard let domain else {
|
|
throw MagicItemError.unknownDomain
|
|
}
|
|
return executeActionForDomainType(
|
|
server: server,
|
|
domain: domain,
|
|
entityId: id,
|
|
state: currentItemState
|
|
)
|
|
case .folder:
|
|
// Folders don't execute actions
|
|
return nil
|
|
}
|
|
}() {
|
|
request.pipe(to: { result in
|
|
switch result {
|
|
case .fulfilled:
|
|
Current.Log.verbose("Success executing magic item \(id)")
|
|
completion(true)
|
|
case let .rejected(error):
|
|
Current.Log.error("Error while executing magic item \(id): \(error.localizedDescription)")
|
|
completion(false)
|
|
}
|
|
})
|
|
} else {
|
|
Current.Log.error("No request available while executing magic item \(id)")
|
|
}
|
|
} catch {
|
|
Current.Log.error("Error while executing magic item (2): \(error.localizedDescription)")
|
|
completion(false)
|
|
}
|
|
}
|
|
|
|
private func executeActionForDomainType(
|
|
server: Server,
|
|
domain: Domain,
|
|
entityId: String,
|
|
state: String
|
|
) -> Promise<Void> {
|
|
var request: HATypedRequest<HAResponseVoid>?
|
|
|
|
// Lock requires state-aware action
|
|
if domain == .lock {
|
|
guard let state = Domain.State(rawValue: state) else { return .value }
|
|
switch state {
|
|
case .unlocking, .unlocked, .opening:
|
|
request = .lockLock(entityId: entityId)
|
|
case .locked, .locking:
|
|
request = .unlockLock(entityId: entityId)
|
|
default:
|
|
break
|
|
}
|
|
} else {
|
|
// Use domain's main action for all other domains
|
|
request = .executeMainAction(domain: domain, entityId: entityId)
|
|
}
|
|
|
|
if let request, let connection = Current.api(for: server)?.connection {
|
|
return connection.send(request).promise
|
|
.map { _ in () }
|
|
} else {
|
|
return .value
|
|
}
|
|
}
|
|
}
|