From 8edb214820959c8115ae0682b84024b99a7abd19 Mon Sep 17 00:00:00 2001 From: Luis Lopes Date: Thu, 9 Mar 2023 18:03:20 +0000 Subject: [PATCH] CarPlay implementation --- .../Entitlements/App-ios.entitlements | 2 + HomeAssistant.xcodeproj/project.pbxproj | 44 +++ Sources/App/AppDelegate.swift | 14 +- .../Resources/en.lproj/Localizable.strings | 42 ++- Sources/App/Scenes/CarPlaySceneDelegate.swift | 268 ++++++++++++++++++ .../Shared/Resources/Swiftgen/Strings.swift | 88 ++++++ .../Extensions/HAEntityExtension.swift | 219 ++++++++++++++ .../Extensions/ServerManagerExtension.swift | 32 +++ .../Templates/DomainsListTemplate.swift | 103 +++++++ .../Templates/EntitiesGridTemplate.swift | 130 +++++++++ 10 files changed, 937 insertions(+), 5 deletions(-) create mode 100644 Sources/App/Scenes/CarPlaySceneDelegate.swift create mode 100644 Sources/Vehicle/Extensions/HAEntityExtension.swift create mode 100644 Sources/Vehicle/Extensions/ServerManagerExtension.swift create mode 100644 Sources/Vehicle/Templates/DomainsListTemplate.swift create mode 100644 Sources/Vehicle/Templates/EntitiesGridTemplate.swift diff --git a/Configuration/Entitlements/App-ios.entitlements b/Configuration/Entitlements/App-ios.entitlements index 99b8dde38..d4738cbf1 100644 --- a/Configuration/Entitlements/App-ios.entitlements +++ b/Configuration/Entitlements/App-ios.entitlements @@ -2,6 +2,8 @@ + com.apple.developer.carplay-driving-task + aps-environment development com.apple.developer.associated-domains diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 25cb8a6e1..fc8c923e7 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -876,6 +876,11 @@ D0FF79D220D87D200034574D /* ClientEventTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FF79D120D87D200034574D /* ClientEventTableViewController.swift */; }; D0FF79D520D87DB10034574D /* ClientEvents.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D0FF79D420D87DB10034574D /* ClientEvents.storyboard */; }; FC8E9421FDB864726918B612 /* Pods-watchOS-WatchExtension-Watch-metadata.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9249824D575933DFA1530BB2 /* Pods-watchOS-WatchExtension-Watch-metadata.plist */; }; + FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */; }; + FD3BC66729BA003B00B19FBE /* HAEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */; }; + FD3BC66929BA008900B19FBE /* ServerManagerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66829BA008900B19FBE /* ServerManagerExtension.swift */; }; + FD3BC66C29BA00D600B19FBE /* EntitiesGridTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66B29BA00D600B19FBE /* EntitiesGridTemplate.swift */; }; + FD3BC66E29BA010A00B19FBE /* DomainsListTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */; }; FD5FEB304713F1E6BFE498DC /* Pods_iOS_Extensions_NotificationService.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE950A9D74B3E7FF5665CB38 /* Pods_iOS_Extensions_NotificationService.framework */; }; /* End PBXBuildFile section */ @@ -2019,6 +2024,11 @@ F3A0FB3BD04C582E655168D0 /* Pods-Tests-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-Tests-App/Pods-Tests-App.release.xcconfig"; sourceTree = ""; }; F3E55AA06795782F04D0B261 /* Pods-iOS-Extensions-Intents.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Extensions-Intents.release.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Extensions-Intents/Pods-iOS-Extensions-Intents.release.xcconfig"; sourceTree = ""; }; F534C18A6FD4884F258341C9 /* Pods-iOS-Shared-iOS.beta.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iOS-Shared-iOS.beta.xcconfig"; path = "Pods/Target Support Files/Pods-iOS-Shared-iOS/Pods-iOS-Shared-iOS.beta.xcconfig"; sourceTree = ""; }; + FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = ""; }; + FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAEntityExtension.swift; sourceTree = ""; }; + FD3BC66829BA008900B19FBE /* ServerManagerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerManagerExtension.swift; sourceTree = ""; }; + FD3BC66B29BA00D600B19FBE /* EntitiesGridTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntitiesGridTemplate.swift; sourceTree = ""; }; + FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainsListTemplate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2217,6 +2227,7 @@ 1115018C2528411200DCFA94 /* Sources */ = { isa = PBXGroup; children = ( + FD3BC66429BA000A00B19FBE /* Vehicle */, B657A8E81CA646EB00121384 /* App */, 111501A72528412C00DCFA94 /* Extensions */, 11DE9D8425B6103C0081C0ED /* Launcher */, @@ -2816,6 +2827,7 @@ 11EFCDDB24F6065F00314D85 /* AboutSceneDelegate.swift */, 11EFCDDF24F60E5900314D85 /* BasicSceneDelegate.swift */, 118261F424F8C7C1000795C6 /* SceneManager.swift */, + FD3BC66229B9FF8F00B19FBE /* CarPlaySceneDelegate.swift */, ); path = Scenes; sourceTree = ""; @@ -3782,6 +3794,33 @@ path = Common; sourceTree = ""; }; + FD3BC66429BA000A00B19FBE /* Vehicle */ = { + isa = PBXGroup; + children = ( + FD3BC66A29BA00B100B19FBE /* Templates */, + FD3BC66529BA001A00B19FBE /* Extensions */, + ); + path = Vehicle; + sourceTree = ""; + }; + FD3BC66529BA001A00B19FBE /* Extensions */ = { + isa = PBXGroup; + children = ( + FD3BC66629BA003B00B19FBE /* HAEntityExtension.swift */, + FD3BC66829BA008900B19FBE /* ServerManagerExtension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + FD3BC66A29BA00B100B19FBE /* Templates */ = { + isa = PBXGroup; + children = ( + FD3BC66B29BA00D600B19FBE /* EntitiesGridTemplate.swift */, + FD3BC66D29BA010A00B19FBE /* DomainsListTemplate.swift */, + ); + path = Templates; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -5403,13 +5442,16 @@ 1187DE4224D77CCC00F0A6A6 /* NFCTagViewController.swift in Sources */, D0EEF324214DF2B700D1D360 /* Utils.swift in Sources */, 1101D7F92621479200AAE617 /* SettingsButtonRow.swift in Sources */, + FD3BC66C29BA00D600B19FBE /* EntitiesGridTemplate.swift in Sources */, B641BC251E20A17B002CCBC1 /* OpenInChromeController.swift in Sources */, B661FB6A226BBDA900E541DD /* SettingsViewController.swift in Sources */, 119D765F2492F8FA00183C5F /* UIApplication+BackgroundTask.swift in Sources */, 11195F6F267EFC8E003DF674 /* NotificationManagerLocalPushInterfaceDirect.swift in Sources */, + FD3BC66329B9FF8F00B19FBE /* CarPlaySceneDelegate.swift in Sources */, 11C4629424B189B100031902 /* NotificationRateLimitsAPI.swift in Sources */, 1161C01B24D7634300A0E3C4 /* NFCListViewController.swift in Sources */, 11A71C6B24A463FC00D9565F /* ZoneManagerState.swift in Sources */, + FD3BC66E29BA010A00B19FBE /* DomainsListTemplate.swift in Sources */, 1185DFAF271FF53800ED7D9A /* OnboardingAuthStepRegister.swift in Sources */, 11F20BC5274B06C100DFB163 /* ServerSelectRow.swift in Sources */, 1130F532253A1E7400F371BE /* ComplicationListViewController.swift in Sources */, @@ -5436,6 +5478,7 @@ 11A71C7124A4648000D9565F /* ZoneManagerEquatableRegion.swift in Sources */, 11E99A5027156854003C8A65 /* OnboardingTerminalViewController.swift in Sources */, 1101568424D770B2009424C9 /* NFCWriter.swift in Sources */, + FD3BC66929BA008900B19FBE /* ServerManagerExtension.swift in Sources */, 11E7C4B02702E03000667342 /* WidgetOpenPageIntent+Observation.swift in Sources */, 1187DE4624D7E1BD00F0A6A6 /* SimulatorNFCManager.swift in Sources */, 1185DF96271FBB9800ED7D9A /* OnboardingAuthLogin.swift in Sources */, @@ -5491,6 +5534,7 @@ 1101568324D770B2009424C9 /* iOSTagManager.swift in Sources */, 11B1FFC524CCD72F00F9BCB2 /* VoiceShortcutRow.swift in Sources */, 1168BF33271809C600DD4D15 /* OnboardingAuthError.swift in Sources */, + FD3BC66729BA003B00B19FBE /* HAEntityExtension.swift in Sources */, B661FB6F226BCCAD00E541DD /* ConnectionSettingsViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index edc1510bf..4f8e393e4 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -201,10 +201,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions ) -> UISceneConfiguration { - let activity = options.userActivities - .compactMap { SceneActivity(activityIdentifier: $0.activityType) } - .first ?? .webView - return activity.configuration + if #available(iOS 16.0, *), connectingSceneSession.role == UISceneSession.Role.carTemplateApplication { + let scene = UISceneConfiguration(name: "CarPlay", sessionRole: connectingSceneSession.role) + scene.delegateClass = CarPlayDelegate.self + return scene + } else { + let activity = options.userActivities + .compactMap { SceneActivity(activityIdentifier: $0.activityType) } + .first ?? .webView + return activity.configuration + } } func application(_ application: UIApplication, shouldRestoreSecureApplicationState coder: NSCoder) -> Bool { diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 7991cb604..3e9e2ace9 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -759,4 +759,44 @@ Home Assistant is free and open source home automation software with a focus on "widgets.open_page.description" = "Open a frontend page in Home Assistant."; "widgets.open_page.not_configured" = "No Pages Available"; "widgets.open_page.title" = "Open Page"; -"yes_label" = "Yes"; \ No newline at end of file +"yes_label" = "Yes"; +"carplay.labels.buttons" = "Buttons"; +"carplay.labels.covers" = "Covers"; +"carplay.labels.input_booleans" = "Input Booleans"; +"carplay.labels.input_buttons" = "Input Buttons"; +"carplay.labels.lights" = "Lights"; +"carplay.labels.locks" = "Locks"; +"carplay.labels.scenes" = "Scenes"; +"carplay.labels.scripts" = "Scripts"; +"carplay.labels.switches" = "Switches"; +"carplay.labels.servers" = "Servers"; +"carplay.labels.empty_domain_list" = "No domains available"; +"carplay.labels.no_servers_available" = "No servers available. Add a server at home assistant Companion App."; +"carplay.labels.already_added_server" = "Already added"; +"state.auto" = "Auto"; +"state.cleaning" = "Cleaning"; +"state.closed" = "Closed"; +"state.closing" = "Closing"; +"state.cool" = "Cool"; +"state.docked" = "Docked"; +"state.dry" = "Dry"; +"state.error" = "Error"; +"state.fan_only" = "Fan Only"; +"state.heat_cool" = "Heat Cool"; +"state.heat" = "Heat"; +"state.idle" = "Idle"; +"state.jammed" = "Jammed"; +"state.locked" = "Locked"; +"state.locking"= "Locking"; +"state.off" = "Off"; +"state.on" = "On"; +"state.open" = "Open"; +"state.opening" = "Opening"; +"state.paused" = "Paused"; +"state.returning" = "Returning"; +"state.unavailable" = "Unavailable"; +"state.unknown" = "Unknown"; +"state.unlocked" = "Unlocked"; +"state.unlocking" = "Unlocking"; +"state.recording" = "Recording"; +"state.streaming" = "Streaming"; diff --git a/Sources/App/Scenes/CarPlaySceneDelegate.swift b/Sources/App/Scenes/CarPlaySceneDelegate.swift new file mode 100644 index 000000000..97f2b0d1a --- /dev/null +++ b/Sources/App/Scenes/CarPlaySceneDelegate.swift @@ -0,0 +1,268 @@ +// +// CarPlayDelegate.swift +// App +// +// Created by Luis Lopes on 15/02/2023. +// Copyright © 2023 Home Assistant. All rights reserved. +// + +import CarPlay +import Shared +import HAKit +import PromiseKit +import Communicator + +public protocol EntitiesStateSubscription { + func subscribe() + func unsubscribe() +} + +@available(iOS 16.0, *) +class CarPlayDelegate : UIResponder { + public static let SUPPORTED_DOMAINS_WITH_STRING = [ + "button" : L10n.Carplay.Labels.buttons, + "cover" : L10n.Carplay.Labels.covers, + "input_boolean" : L10n.Carplay.Labels.inputBooleans, + "input_button" : L10n.Carplay.Labels.inputButtons, + "light" : L10n.Carplay.Labels.lights, + "lock" : L10n.Carplay.Labels.locks, + "scene" : L10n.Carplay.Labels.scenes, + "script" : L10n.Carplay.Labels.scripts, + "switch" : L10n.Carplay.Labels.switches + ] + + public let SUPPORTED_DOMAINS = SUPPORTED_DOMAINS_WITH_STRING.keys + + private var MAP_DOMAINS = [ + "device_tracker", + "person", + "sensor", + "zone" + ] + + private var interfaceController: CPInterfaceController? + private var filteredEntities: [HAEntity] = [] + private var entitiesGridTemplate : EntitiesGridTemplate? + private var domainsListTemplate : DomainsListTemplate? + private var entitiesStateSubscribeCancelable : HACancellable? + private var serverObserver : HACancellable? + private var serverId : Identifier? { + didSet { + loadEntities() + } + } + + let prefs = UserDefaults(suiteName: Constants.AppGroupID)! + + func loadEntities() { + self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) + + guard let serverId = serverId, let server = Current.servers.server(for: serverId) else { + Current.Log.info("No server available to get entities") + filteredEntities.removeAll() + self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) + return + } + + guard let allServerEntities = Current.api(for: server).connection.caches.states.value?.all else { + Current.Log.info("No entities available from server \(server.info.name)") + filteredEntities.removeAll() + self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) + interfaceController?.setRootTemplate(self.domainsListTemplate!.getTemplate(), animated: false) + return + } + + filteredEntities = getFilteredAndSortEntities(entities: Array(allServerEntities)) + self.domainsListTemplate?.entitiesUpdate(updateEntities: filteredEntities) + if let template = self.domainsListTemplate?.getTemplate() { + interfaceController?.setRootTemplate(template, animated: false) + } + } + + func getFilteredAndSortEntities(entities: [HAEntity]) -> [HAEntity] { + var tmpEntities : [HAEntity] = [] + + for entity in entities where SUPPORTED_DOMAINS.contains(entity.domain) { + tmpEntities.append(entity) + } + return tmpEntities.sorted(by: {$0.getFriendlyState() < $1.getFriendlyState()}) + } + + func setServer(server: Server) { + serverId = server.identifier + serverObserver = server.observe { [weak self] _ in + self?.connectionInfoDidChange() + } + + entitiesStateSubscribeCancelable?.cancel() + prefs.set(server.identifier.rawValue, forKey: "carPlay-server") + subscribeEntitiesUpdates(for: server) + } + + @objc private func connectionInfoDidChange() { + DispatchQueue.main.async { + self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) + if self.serverId == nil { + ///No server is selected + guard let server = Current.servers.getServer() else { + Current.Log.info("No server connected") + return + } + self.setServer(server: server) + } + } + } + + func subscribeEntitiesUpdates(for server: Server) { + Current.Log.info("Subscribe for entities update at server \(server.info.name)") + entitiesStateSubscribeCancelable?.cancel() + entitiesStateSubscribeCancelable = Current.api(for: server).connection.caches.states.subscribe { [weak self] cancellable, cachedStates in + Current.Log.info("Received entities update of server \(server.info.name)") + guard let self = self else { + cancellable.cancel() + return + } + + self.loadEntities() + } + } + + //Templates + + func showNoServerAlert() { + guard self.interfaceController?.presentedTemplate == nil else { + return + } + + let loginAlertAction : CPAlertAction = CPAlertAction(title: L10n.Carplay.Labels.alreadyAddedServer, style: .default) { _ in + if !Current.servers.all.isEmpty { + self.interfaceController?.dismissTemplate(animated: true) + } + } + let alertTemplate = CPAlertTemplate(titleVariants: [L10n.Carplay.Labels.noServersAvailable], actions: [loginAlertAction]) + self.interfaceController?.presentTemplate(alertTemplate, animated: true) + } + + func setDomainListTemplate() { + domainsListTemplate = DomainsListTemplate(title: L10n.About.Logo.appTitle, entities: filteredEntities, ic : interfaceController!, + listItemHandler: {[weak self] domain, entities in + + guard let self = self, let server = Current.servers.getServer(id: self.serverId) else { + return + } + + let itemTitle = CarPlayDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain + self.entitiesGridTemplate = EntitiesGridTemplate(title: itemTitle, domain: domain, server: server, entities: entities) + self.interfaceController?.pushTemplate(self.entitiesGridTemplate!.getTemplate(), animated: true) + }, serverButtonHandler: { _ in + self.setServerListTemplate() + }) + + interfaceController?.setRootTemplate(domainsListTemplate!.getTemplate(), animated: true) + } + + func setServerListTemplate() { + var serverList : [CPListItem] = [] + for server in Current.servers.all { + let serverItem = CPListItem(text: server.info.name, detailText: "\(server.info.connection.activeURLType.description) - \(server.info.connection.activeURL().absoluteString)") + serverItem.handler = { [weak self] item, completion in + self?.setServer(server: server) + if let templates = self?.interfaceController?.templates, templates.count > 1 { + self?.interfaceController?.popTemplate(animated: true) + } + completion() + } + serverItem.accessoryType = self.serverId == server.identifier ? .cloud : .none + serverList.append(serverItem) + } + let section = CPListSection(items: serverList) + let serverListTemplate = CPListTemplate(title: L10n.Carplay.Labels.servers, sections: [section]) + self.interfaceController?.pushTemplate(serverListTemplate, animated: true) + } +} + +@available(iOS 16.0, *) +extension CarPlayDelegate : CPTemplateApplicationSceneDelegate { + func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) { + self.interfaceController = interfaceController + self.interfaceController?.delegate = self + + /// Observer for servers list changes + Current.servers.add(observer: self) + + setDomainListTemplate() + + if Current.servers.all.isEmpty { + showNoServerAlert() + } + + if Current.servers.isConnected() { + if let serverIdentifier = prefs.string(forKey: "carPlay-server"), + let selectedServer = Current.servers.server(forServerIdentifier: serverIdentifier) { + setServer(server: selectedServer) + } else if let server = Current.servers.getServer() { + setServer(server: server) + } + } + + NotificationCenter.default.addObserver( + self, + selector: #selector(connectionInfoDidChange), + name: HAConnectionState.didTransitionToStateNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(connectionInfoDidChange), + name: HomeAssistantAPI.didConnectNotification, + object: nil + ) + } + + func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didDisconnect interfaceController: CPInterfaceController, from window: CPWindow) { + entitiesStateSubscribeCancelable?.cancel() + entitiesStateSubscribeCancelable = nil + NotificationCenter.default.removeObserver(self) + Current.servers.remove(observer: self) + serverObserver?.cancel() + serverObserver = nil + } +} + +@available(iOS 16.0, *) +extension CarPlayDelegate: CPInterfaceControllerDelegate { + + func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) { + if aTemplate == entitiesGridTemplate?.getTemplate() { + entitiesGridTemplate?.subscribe() + } + } + + func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) { + if aTemplate == entitiesGridTemplate?.getTemplate() { + entitiesGridTemplate?.unsubscribe() + } + } +} + +@available(iOS 16.0, *) +extension CarPlayDelegate : ServerObserver { + func serversDidChange(_ serverManager: ServerManager) { + self.domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1) + + if Current.servers.getServer(id: serverId) == nil { + serverId = nil + } + if serverId == nil, let server = Current.servers.getServer() { + setServer(server: server) + } + if serverManager.all.isEmpty { + entitiesStateSubscribeCancelable?.cancel() + entitiesStateSubscribeCancelable = nil + showNoServerAlert() + } else if self.interfaceController?.presentedTemplate != nil { + self.interfaceController?.dismissTemplate(animated: true) + } + } +} diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index e592407e7..614c30343 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -216,6 +216,37 @@ public enum L10n { } } + public enum Carplay { + public enum Labels { + /// Already added + public static var alreadyAddedServer: String { return L10n.tr("Localizable", "carplay.labels.already_added_server") } + /// Buttons + public static var buttons: String { return L10n.tr("Localizable", "carplay.labels.buttons") } + /// Covers + public static var covers: String { return L10n.tr("Localizable", "carplay.labels.covers") } + /// No domains available + public static var emptyDomainList: String { return L10n.tr("Localizable", "carplay.labels.empty_domain_list") } + /// Input Booleans + public static var inputBooleans: String { return L10n.tr("Localizable", "carplay.labels.input_booleans") } + /// Input Buttons + public static var inputButtons: String { return L10n.tr("Localizable", "carplay.labels.input_buttons") } + /// Lights + public static var lights: String { return L10n.tr("Localizable", "carplay.labels.lights") } + /// Locks + public static var locks: String { return L10n.tr("Localizable", "carplay.labels.locks") } + /// No servers available. Add a server at home assistant Companion App. + public static var noServersAvailable: String { return L10n.tr("Localizable", "carplay.labels.no_servers_available") } + /// Scenes + public static var scenes: String { return L10n.tr("Localizable", "carplay.labels.scenes") } + /// Scripts + public static var scripts: String { return L10n.tr("Localizable", "carplay.labels.scripts") } + /// Servers + public static var servers: String { return L10n.tr("Localizable", "carplay.labels.servers") } + /// Switches + public static var switches: String { return L10n.tr("Localizable", "carplay.labels.switches") } + } + } + public enum ClError { public enum Description { /// Deferred mode is not supported for the requested accuracy. @@ -1755,6 +1786,63 @@ public enum L10n { } } + public enum State { + /// Auto + public static var auto: String { return L10n.tr("Localizable", "state.auto") } + /// Cleaning + public static var cleaning: String { return L10n.tr("Localizable", "state.cleaning") } + /// Closed + public static var closed: String { return L10n.tr("Localizable", "state.closed") } + /// Closing + public static var closing: String { return L10n.tr("Localizable", "state.closing") } + /// Cool + public static var cool: String { return L10n.tr("Localizable", "state.cool") } + /// Docked + public static var docked: String { return L10n.tr("Localizable", "state.docked") } + /// Dry + public static var dry: String { return L10n.tr("Localizable", "state.dry") } + /// Error + public static var error: String { return L10n.tr("Localizable", "state.error") } + /// Fan Only + public static var fanOnly: String { return L10n.tr("Localizable", "state.fan_only") } + /// Heat + public static var heat: String { return L10n.tr("Localizable", "state.heat") } + /// Heat Cool + public static var heatCool: String { return L10n.tr("Localizable", "state.heat_cool") } + /// Idle + public static var idle: String { return L10n.tr("Localizable", "state.idle") } + /// Jammed + public static var jammed: String { return L10n.tr("Localizable", "state.jammed") } + /// Locked + public static var locked: String { return L10n.tr("Localizable", "state.locked") } + /// Locking + public static var locking: String { return L10n.tr("Localizable", "state.locking") } + /// Off + public static var off: String { return L10n.tr("Localizable", "state.off") } + /// On + public static var on: String { return L10n.tr("Localizable", "state.on") } + /// Open + public static var `open`: String { return L10n.tr("Localizable", "state.open") } + /// Opening + public static var opening: String { return L10n.tr("Localizable", "state.opening") } + /// Paused + public static var paused: String { return L10n.tr("Localizable", "state.paused") } + /// Recording + public static var recording: String { return L10n.tr("Localizable", "state.recording") } + /// Returning + public static var returning: String { return L10n.tr("Localizable", "state.returning") } + /// Streaming + public static var streaming: String { return L10n.tr("Localizable", "state.streaming") } + /// Unavailable + public static var unavailable: String { return L10n.tr("Localizable", "state.unavailable") } + /// Unknown + public static var unknown: String { return L10n.tr("Localizable", "state.unknown") } + /// Unlocked + public static var unlocked: String { return L10n.tr("Localizable", "state.unlocked") } + /// Unlocking + public static var unlocking: String { return L10n.tr("Localizable", "state.unlocking") } + } + public enum TokenError { /// Connection failed. public static var connectionFailed: String { return L10n.tr("Localizable", "token_error.connection_failed") } diff --git a/Sources/Vehicle/Extensions/HAEntityExtension.swift b/Sources/Vehicle/Extensions/HAEntityExtension.swift new file mode 100644 index 000000000..8719bc7a6 --- /dev/null +++ b/Sources/Vehicle/Extensions/HAEntityExtension.swift @@ -0,0 +1,219 @@ +// +// HAEntityExtentions.swift +// App +// +// Created by Luis Lopes on 27/02/2023. +// Copyright © 2023 Home Assistant. All rights reserved. +// + +import Foundation +import HAKit +import Shared +import PromiseKit +import SwiftUI + +extension HAEntity { + + public static func getIconForDomain(domain: String, size: CGSize) -> UIImage? { + do { + let tmpEntity = try HAEntity(entityId: "\(domain).ha_ios_placeholder", domain: domain, state: "", lastChanged: Date(), lastUpdated: Date(), attributes: [:], context: HAResponseEvent.Context(id: "", userId: nil, parentId: nil)) + return tmpEntity.getIcon(size: size) + } + catch { + return nil + } + } + + func onPress(for api: HomeAssistantAPI) -> Promise{ + let domain = domain + var service : String + switch (domain) { + case "lock": + service = state == "unlocked" ? "lock" : "unlock" + case "cover": + service = state == "open" ? "close_cover" : "open_cover" + case "button","input_button": + service = "press"; + case "scene": + service = "turn_on"; + default: + service = state == "on" ? "turn_off" : "turn_on"; + } + return api.CallService(domain: domain, service: service, serviceData: ["entity_id" : entityId]) + } + + func getIcon(size: CGSize = CGSize(width: 64, height: 64), darkColor: UIColor = UIColor.white) -> UIImage?{ + var icon = attributes.icon ?? "" + + var image : MaterialDesignIcons = MaterialDesignIcons.bookmarkIcon + + if icon.starts(with: "mdi:") { + let mdiIcon = icon.components(separatedBy: ":")[1] + let iconName = mdiIcon.replacingOccurrences(of: "-", with: "_") + image = MaterialDesignIcons(named: iconName) + } else { + var compareState = state + switch (domain) { + case "button": + guard let deviceClass = attributes.dictionary["device_class"] as? String else { break } + if (deviceClass == "restart") { + image = MaterialDesignIcons.restartIcon + } else if (deviceClass == "update") { + image = MaterialDesignIcons.packageUpIcon + } else { + image = MaterialDesignIcons.gestureTapButtonIcon + } + case "cover": + image = getCoverIcon() + case "input_boolean": + if (!entityId.hasSuffix(".ha_ios_placeholder")) { + if (compareState == "on") { + image = MaterialDesignIcons.checkCircleOutlineIcon + } else { + image = MaterialDesignIcons.closeCircleOutlineIcon + } + } else { + image = MaterialDesignIcons.toggleSwitchOutlineIcon + } + case "input_button": + image = MaterialDesignIcons.gestureTapButtonIcon + case "light": + image = MaterialDesignIcons.lightbulbIcon + case "lock": + switch (compareState) { + case "unlocked": + image = MaterialDesignIcons.lockOpenIcon + case "jammed": + image = MaterialDesignIcons.lockAlertIcon + case "locking", "unlocking": + image = MaterialDesignIcons.lockClockIcon + default: + image = MaterialDesignIcons.lockIcon + } + case "person": + image = MaterialDesignIcons.accountIcon + case "scene": + image = MaterialDesignIcons.paletteOutlineIcon + case "script": + image = MaterialDesignIcons.scriptTextOutlineIcon + case "sensor": + image = MaterialDesignIcons.eyeIcon + case "switch": + if (!entityId.hasSuffix(".ha_ios_placeholder")) { + let deviceClass = attributes.dictionary["device_class"] as? String + switch(deviceClass) { + case "outlet": + image = compareState == "on" ? MaterialDesignIcons.powerPlugIcon : MaterialDesignIcons.powerPlugOffIcon + case "switch": + image = compareState == "on" ? MaterialDesignIcons.toggleSwitchIcon : MaterialDesignIcons.toggleSwitchOffIcon + default: + image = MaterialDesignIcons.flashIcon + } + } else { + image = MaterialDesignIcons.lightSwitchIcon + } + case "zone": + image = MaterialDesignIcons.mapMarkerRadiusIcon + default: + image = MaterialDesignIcons.bookmarkIcon + } + } + var iconImage = image.image(ofSize: size, color: nil) + iconImage.imageAsset?.register(image.image(ofSize: size, color: darkColor), with: .init(userInterfaceStyle: .dark)) + return iconImage + } + + private func getCoverIcon() -> MaterialDesignIcons { + let device_class = attributes.dictionary["device_class"] as? String + let state = state + let open = state != "closed" + + switch (device_class) { + case "garage": + switch (state) { + case "opening": return MaterialDesignIcons.arrowUpBoxIcon + case "closing": return MaterialDesignIcons.arrowDownBoxIcon + case "closed": return MaterialDesignIcons.garageIcon + default: return MaterialDesignIcons.garageOpenIcon + } + case "gate": + switch (state) { + case "opening", "closing": return MaterialDesignIcons.gateArrowRightIcon + case "closed": return MaterialDesignIcons.gateIcon + default: return MaterialDesignIcons.gateOpenIcon + } + case "door": + return open ? MaterialDesignIcons.doorOpenIcon : MaterialDesignIcons.doorClosedIcon + case "damper": + return open ? MaterialDesignIcons.circleIcon : MaterialDesignIcons.circleSlice8Icon + case "shutter": + switch (state) { + case "opening": return MaterialDesignIcons.arrowUpBoxIcon + case "closing": return MaterialDesignIcons.arrowDownBoxIcon + case "closed": return MaterialDesignIcons.windowShutterIcon + default: return MaterialDesignIcons.windowShutterOpenIcon + } + case "curtain": + switch (state) { + case "opening": return MaterialDesignIcons.arrowSplitVerticalIcon + case "closing": return MaterialDesignIcons.arrowCollapseHorizontalIcon + case "closed": return MaterialDesignIcons.curtainsClosedIcon + default: return MaterialDesignIcons.curtainsIcon + } + case "blind", "shade": + switch (state) { + case "opening": return MaterialDesignIcons.arrowUpBoxIcon + case "closing": return MaterialDesignIcons.arrowDownBoxIcon + case "closed": return MaterialDesignIcons.blindsIcon + default: return MaterialDesignIcons.blindsOpenIcon + } + default: + switch (state) { + case "opening": return MaterialDesignIcons.arrowUpBoxIcon + case "closing": return MaterialDesignIcons.arrowDownBoxIcon + case "closed": return MaterialDesignIcons.windowClosedIcon + default: return MaterialDesignIcons.windowOpenIcon + } + } + } + + func getFriendlyState() -> String { + var state = state + var friendlyState : String = state + switch(state) { + case "closed": + friendlyState = L10n.State.closed + case "closing": + friendlyState = L10n.State.closing + case "jammed": + friendlyState = L10n.State.jammed + case "locked": + friendlyState = L10n.State.locked + case "locking": + friendlyState = L10n.State.locking + case "off": + friendlyState = L10n.State.off + case "on": + friendlyState = L10n.State.on + case "open": + friendlyState = L10n.State.open + case "opening": + friendlyState = L10n.State.opening + case "unavailable": + friendlyState = L10n.State.unavailable + case "unlocked": + friendlyState = L10n.State.unlocked + case "unlocking": + friendlyState = L10n.State.unlocking + case "unknown": + friendlyState = L10n.State.unknown + default: + break + } + + if (friendlyState == state) { + friendlyState = "\((Date().timeIntervalSinceReferenceDate - lastChanged.timeIntervalSinceReferenceDate).rounded().description) sec" + } + return friendlyState + } +} diff --git a/Sources/Vehicle/Extensions/ServerManagerExtension.swift b/Sources/Vehicle/Extensions/ServerManagerExtension.swift new file mode 100644 index 000000000..9ddf87daa --- /dev/null +++ b/Sources/Vehicle/Extensions/ServerManagerExtension.swift @@ -0,0 +1,32 @@ +// +// ServerManagerExtension.swift +// App +// +// Created by Luis Lopes on 06/03/2023. +// Copyright © 2023 Home Assistant. All rights reserved. +// + +import Foundation +import Shared + +extension ServerManager { + public func isConnected() -> Bool { + return all.contains(where: { isConnected(server: $0) }) + } + + public func isConnected(server : Server) -> Bool{ + switch Current.api(for: server).connection.state { + case .ready(version: _): + return true + default: + return false + } + } + + public func getServer(id : Identifier? = nil) -> Server? { + guard let id = id else { + return all.first(where: {isConnected(server: $0)} ) + } + return server(for: id) + } +} diff --git a/Sources/Vehicle/Templates/DomainsListTemplate.swift b/Sources/Vehicle/Templates/DomainsListTemplate.swift new file mode 100644 index 000000000..70499f55c --- /dev/null +++ b/Sources/Vehicle/Templates/DomainsListTemplate.swift @@ -0,0 +1,103 @@ +// +// DomainListTemplate.swift +// App +// +// Created by Luis Lopes on 27/02/2023. +// Copyright © 2023 Home Assistant. All rights reserved. +// + +import Foundation +import CarPlay +import HAKit +import Shared + +@available(iOS 16.0, *) +class DomainsListTemplate { + private var title : String + private var listTemplate : CPListTemplate? + private var entities : [HAEntity] + private let listItemHandler : (String, [HAEntity]) -> Void + private var serverButtonHandler: CPBarButtonHandler? + private var domainList : [String] = [] + + init(title: String, entities: [HAEntity], ic: CPInterfaceController, + listItemHandler: @escaping (String, [HAEntity]) -> Void, + serverButtonHandler: CPBarButtonHandler? = nil) + { + self.title = title + self.entities = entities + self.listItemHandler = listItemHandler + self.serverButtonHandler = serverButtonHandler + } + + public func getTemplate() -> CPListTemplate { + guard let listTemplate = listTemplate else { + listTemplate = CPListTemplate(title: title, sections: []) + listTemplate?.emptyViewSubtitleVariants = [L10n.Carplay.Labels.emptyDomainList] + return listTemplate! + } + return listTemplate + } + + public func entitiesUpdate(updateEntities : [HAEntity]) { + entities = updateEntities + updateSection() + } + + func setServerListButton(show : Bool) { + if show { + listTemplate?.trailingNavigationBarButtons = [CPBarButton(title: L10n.Carplay.Labels.servers, handler: serverButtonHandler)] + } else { + listTemplate?.trailingNavigationBarButtons.removeAll() + } + } + + func updateSection() { + let allUniqueDomains = entities.unique(by: {$0.domain}) + let domainsSorted = allUniqueDomains.sorted { $0.domain < $1.domain } + let domains = domainsSorted.map { $0.domain } + + guard domainList != domains else { + return + } + + var items : [CPListItem] = [] + + for domain in domains { + + let itemTitle = CarPlayDelegate.SUPPORTED_DOMAINS_WITH_STRING[domain] ?? domain + let listItem = CPListItem(text: itemTitle, detailText: nil, image: HAEntity.getIconForDomain(domain: domain, size: CPListItem.maximumImageSize)) + listItem.accessoryType = CPListItemAccessoryType.disclosureIndicator + listItem.handler = { [weak self] item, completion in + if let entitiesForSelectedDomain = self?.getEntitiesForDomain(domain: domain) { + self?.listItemHandler(domain, entitiesForSelectedDomain) + } + completion() + } + + items.append(listItem) + } + + domainList = domains + listTemplate?.updateSections([CPListSection(items: items)]) + } + + func getEntitiesForDomain(domain: String) -> [HAEntity] { + return entities.filter {$0.domain == domain} + } +} + +extension Array { + func unique(by: ((Element) -> (T))) -> [Element] { + var set = Set() + var arrayOrdered = [Element]() + for value in self { + let v = by(value) + if !set.contains(v) { + set.insert(v) + arrayOrdered.append(value) + } + } + return arrayOrdered + } +} diff --git a/Sources/Vehicle/Templates/EntitiesGridTemplate.swift b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift new file mode 100644 index 000000000..89882c5c8 --- /dev/null +++ b/Sources/Vehicle/Templates/EntitiesGridTemplate.swift @@ -0,0 +1,130 @@ +// +// EntityGridTemplate.swift +// App +// +// Created by Luis Lopes on 27/02/2023. +// Copyright © 2023 Home Assistant. All rights reserved. +// + +import Foundation +import CarPlay +import HAKit +import Shared +import PromiseKit + +@available(iOS 16.0, *) +class EntitiesGridTemplate { + + private let entityIconSize : CGSize = CGSize(width: 64, height: 64) + private var stateSubscriptionToken : HACancellable? + private let title : String + private let domain : String + private var server : Server + private var entities : [HAEntity] = [] + private var gridTemplate: CPGridTemplate? + private var gridPage: Int = 0 + + enum GridPage { + case Next + case Previous + } + + init(title: String, domain: String, server: Server, entities: [HAEntity]) { + self.title = title + self.domain = domain + self.server = server + self.entities = entities + } + + public func getTemplate() -> CPGridTemplate { + guard let gridTemplate = gridTemplate else { + gridTemplate = CPGridTemplate(title: title, gridButtons: getGridButtons()) + return gridTemplate! + } + return gridTemplate + } + + func getGridButtons() -> [CPGridButton] { + var items: [CPGridButton] = [] + + let entitiesSorted = entities.sorted(by: { $0.attributes.friendlyName ?? "" < $1.attributes.friendlyName ?? "" }) + + let entitiesPage = entitiesSorted[(gridPage * CPGridTemplateMaximumItems) ..< min((gridPage * CPGridTemplateMaximumItems) + CPGridTemplateMaximumItems, entitiesSorted.count)] + + for entity in entitiesPage { + let item = CPGridButton(titleVariants: ["\(entity.attributes.friendlyName!) - \(entity.getFriendlyState())"], image: entity.getIcon() ?? MaterialDesignIcons.bookmarkIcon.image(ofSize: entityIconSize, color: nil), handler: { button in + firstly { () -> Promise in + let api = Current.api(for: self.server) + return entity.onPress(for: api) + }.done { + }.catch { error in + Current.Log.error("Received error from callService during onPress call: \(error)") + } + }) + items.append(item) + } + return items + } + + func getPageButtons() -> [CPBarButton] { + var barButtons : [CPBarButton] = [] + if entities.count > CPGridTemplateMaximumItems { + let maxPages = entities.count / CPGridTemplateMaximumItems + if gridPage < maxPages { + barButtons.append(CPBarButton(image: MaterialDesignIcons.pageNextIcon.image(ofSize: CPButtonMaximumImageSize, color: nil), handler: { CPBarButton in + self.changePage(to: .Next) + })) + } else { + barButtons.append(CPBarButton(image: UIImage(size: CPButtonMaximumImageSize, color: UIColor.clear), handler: nil)) + } + if gridPage > 0 { + barButtons.append(CPBarButton(image: MaterialDesignIcons.pagePreviousIcon.image(ofSize: CPButtonMaximumImageSize, color: nil), handler: { CPBarButton in + self.changePage(to: .Previous) + })) + } else { + barButtons.append(CPBarButton(image: UIImage(size: CPButtonMaximumImageSize, color: UIColor.clear), handler: nil)) + } + } else { + gridPage = 0 + } + return barButtons + } + + func changePage(to: GridPage) { + switch to { + case .Next: + self.gridPage+=1 + case .Previous: + self.gridPage-=1 + } + gridTemplate?.updateGridButtons(getGridButtons()) + gridTemplate?.trailingNavigationBarButtons = getPageButtons() + } +} + +@available(iOS 16.0, *) +extension EntitiesGridTemplate : EntitiesStateSubscription { + public func subscribe() { + stateSubscriptionToken = Current.api(for: server).connection.caches.states.subscribe { [self] cancellable, cachedStates in + entities.removeAll { entity in + !cachedStates.all.contains(where: {$0.entityId == entity.entityId}) + } + + for entity in cachedStates.all where entity.domain == domain { + if let index = entities.firstIndex(where: {$0.entityId == entity.entityId}) { + entities[index] = entity + } else { + entities.append(entity) + } + } + + gridTemplate?.updateGridButtons(getGridButtons()) + gridTemplate?.trailingNavigationBarButtons = getPageButtons() + } + } + + public func unsubscribe() { + stateSubscriptionToken?.cancel() + stateSubscriptionToken = nil + } +}