Skip to content

Commit

Permalink
CarPlay implementation (#2320)
Browse files Browse the repository at this point in the history
<!-- Thank you for submitting a Pull Request and helping to improve Home
Assistant. Please complete the following sections to help the processing
and review of your changes. Please do not delete anything from this
template. -->

## Summary
<!-- Provide a brief summary of the changes you have made and most
importantly what they aim to achieve -->
CarPlay implementation that allows you to interact with various entities
safely while driving your vehicle.

## Screenshots
<!-- If this is a user-facing change not in the frontend, please include
screenshots in light and dark mode. -->

## Link to pull request in Documentation repository
<!-- Pull requests that add, change or remove functionality must have a
corresponding pull request in the Companion App Documentation repository
(https://github.com/home-assistant/companion.home-assistant). Please add
the number of this pull request after the "#" -->
Documentation: home-assistant/companion.home-assistant#923

## Any other notes
<!-- If there is any other information of note, like if this Pull
Request is part of a bigger change, please include it here. -->

---------

Co-authored-by: Joshua Peisach <[email protected]>
Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Bruno Pantaleão <[email protected]>
  • Loading branch information
4 people authored Jan 8, 2024
1 parent f04c5f4 commit 14081d7
Show file tree
Hide file tree
Showing 17 changed files with 1,139 additions and 6 deletions.
11 changes: 11 additions & 0 deletions Configuration/Entitlements/activate_special_entitlements.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ if [[ $TARGET_NAME = "App" ]]; then
fi
fi

if [[ $TARGET_NAME = "App" ]]; then
if [[ $CI && $CONFIGURATION != "Release" ]]; then
echo "warning: com.apple.developer.carplay-driving-task disabled for CI"
elif [[ ${ENABLE_CARPLAY} -eq 1 ]]; then
/usr/libexec/PlistBuddy -c "add com.apple.developer.carplay-driving-task bool true" "$ENTITLEMENTS_FILE"
else
echo "warning: com.apple.developer.carplay-driving-task entitlement disabled"
fi
fi


if [[ $TARGET_NAME = "App" ]]; then
if [[ $CI && $CONFIGURATION != "Release" ]]; then
echo "warning: Device name disabled for CI"
Expand Down
2 changes: 2 additions & 0 deletions Configuration/HomeAssistant.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ ENABLE_CRITICAL_ALERTS_QMQYCKL255 = 1
ENABLE_PUSH_PROVIDER_QMQYCKL255 = 1
ENABLE_DEVICE_NAME_QMQYCKL255 = 1
ENABLE_THREAD_NETWORK_CREDENTIALS_QMQYCKL255 = 1
ENABLE_CARPLAY_QMQYCKL255 = 1

// cascades down
PRODUCT_BUNDLE_IDENTIFIER = ${BUNDLE_ID_PREFIX}.HomeAssistant${BUNDLE_ID_SUFFIX}${PROVISIONING_SUFFIX}
Expand All @@ -30,6 +31,7 @@ ENABLE_CRITICAL_ALERTS[sdk=iphoneos*] = $(ENABLE_CRITICAL_ALERTS_$(DEVELOPMENT_T
ENABLE_PUSH_PROVIDER[sdk=iphoneos*] = $(ENABLE_PUSH_PROVIDER_$(DEVELOPMENT_TEAM))
ENABLE_DEVICE_NAME[sdk=iphoneos*] = $(ENABLE_DEVICE_NAME_$(DEVELOPMENT_TEAM))
ENABLE_THREAD_NETWORK_CREDENTIALS[sdk=iphoneos*] = $(ENABLE_THREAD_NETWORK_CREDENTIALS_$(DEVELOPMENT_TEAM))
ENABLE_CARPLAY[sdk=iphoneos*] = $(ENABLE_CARPLAY_$(DEVELOPMENT_TEAM))

// We mutate the entitlements at build time to support other development teams
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES
Expand Down
76 changes: 76 additions & 0 deletions HomeAssistant.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions Sources/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,14 @@ 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 {
return SceneActivity.carPlay.configuration
} else {
let activity = options.userActivities
.compactMap { SceneActivity(activityIdentifier: $0.activityType) }
.first ?? .webView
return activity.configuration
}
}

func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
Expand Down
11 changes: 11 additions & 0 deletions Sources/App/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>CPTemplateApplicationScene</string>
<key>UISceneConfigurationName</key>
<string>CarPlay</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
</dict>
</array>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
Expand Down
12 changes: 11 additions & 1 deletion Sources/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"about.home_assistant_on_facebook.title" = "Home Assistant on Facebook";
"about.home_assistant_on_twitter.title" = "Home Assistant on Twitter";
"about.logo.app_title" = "Home Assistant Companion";
"about.logo.title" = "Home Assistant";
"about.logo.tagline" = "Awaken Your Home";
"about.review.title" = "Leave a review";
"about.title" = "About";
Expand All @@ -32,6 +33,7 @@
"alerts.auth_required.message" = "The server has rejected your credentials, and you must sign in again to continue.";
"alerts.auth_required.title" = "You must sign in to continue";
"alerts.confirm.cancel" = "Cancel";
"alerts.confirm.confirm" = "Confirm";
"alerts.confirm.ok" = "OK";
"alerts.deprecations.notification_category.message" = "You must migrate to actions defined in the notification itself before %1$@.";
"alerts.deprecations.notification_category.title" = "Notification Categories are deprecated";
Expand Down Expand Up @@ -784,4 +786,12 @@ 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";
"yes_label" = "Yes";
"carplay.navigation.button.next" = "Next";
"carplay.navigation.button.previous" = "Previous";
"carplay.labels.servers" = "Servers";
"carplay.labels.empty_domain_list" = "No domains available";
"carplay.labels.no_servers_available" = "No servers available. Add a server in the app.";
"carplay.labels.already_added_server" = "Already added";
"carplay.lock.confirmation.title" = "Are you sure you want to perform lock action on %@?";
"carplay.unlock.confirmation.title" = "Are you sure you want to perform unlock action on %@?";
221 changes: 221 additions & 0 deletions Sources/App/Scenes/CarPlaySceneDelegate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import CarPlay
import Communicator
import HAKit
import PromiseKit
import Shared

public protocol EntitiesStateSubscription {
func subscribe()
func unsubscribe()
}

@available(iOS 16.0, *)
class CarPlaySceneDelegate: UIResponder {
private var interfaceController: CPInterfaceController?
private var entities: HACache<Set<HAEntity>>?
private var domainsListTemplate: DomainsListTemplate?
private var serverId: Identifier<Server>?

private let carPlayPreferredServerKey = "carPlay-server"

private func setServer(server: Server) {
serverId = server.identifier
prefs.set(server.identifier.rawValue, forKey: carPlayPreferredServerKey)
setDomainListTemplate(for: server)
updateServerListButton()
}

private func updateServerListButton() {
domainsListTemplate?.setServerListButton(show: Current.servers.all.count > 1)
}

@objc private func updateServerList() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.updateServerListButton()
if self.serverId == nil {
/// No server is selected
guard let server = self.getServer() else {
Current.Log.info("No server connected")
return
}
self.setServer(server: server)
}
}
}

private func showNoServerAlert() {
guard interfaceController?.presentedTemplate == nil else {
return
}

let loginAlertAction = CPAlertAction(title: L10n.Carplay.Labels.alreadyAddedServer, style: .default) { _ in
if !Current.servers.all.isEmpty {
self.interfaceController?.dismissTemplate(animated: true, completion: nil)
}
}
let alertTemplate = CPAlertTemplate(
titleVariants: [L10n.Carplay.Labels.noServersAvailable],
actions: [loginAlertAction]
)
interfaceController?.presentTemplate(alertTemplate, animated: true, completion: nil)
}

private func setDomainListTemplate(for server: Server) {
guard let interfaceController else { return }

let entities = Current.api(for: server).connection.caches.states

domainsListTemplate = DomainsListTemplate(
title: server.info.name,
entities: entities,
serverButtonHandler: { [weak self] _ in
self?.setServerListTemplate()
},
server: server
)

guard let domainsListTemplate else { return }

domainsListTemplate.interfaceController = interfaceController

interfaceController.setRootTemplate(domainsListTemplate.template, animated: true, completion: nil)
domainsListTemplate.updateSections()
}

private 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] _, completion in
self?.setServer(server: server)
if let templates = self?.interfaceController?.templates, templates.count > 1 {
self?.interfaceController?.popTemplate(animated: true, completion: nil)
}
completion()
}
serverItem.accessoryType = serverId == server.identifier ? .cloud : .none
serverList.append(serverItem)
}
let section = CPListSection(items: serverList)
let serverListTemplate = CPListTemplate(title: L10n.Carplay.Labels.servers, sections: [section])
interfaceController?.pushTemplate(serverListTemplate, animated: true, completion: nil)
}

private func setEmptyTemplate(interfaceController: CPInterfaceController) {
interfaceController.setRootTemplate(CPInformationTemplate(
title: L10n.About.Logo.title,
layout: .leading,
items: [],
actions: []
), animated: true, completion: nil)
}

/// Get server for ID or first server available
private func getServer(id: Identifier<Server>? = nil) -> Server? {
guard let id = id else {
return Current.servers.all.first
}
return Current.servers.server(for: id)
}
}

// MARK: - CPTemplateApplicationSceneDelegate

@available(iOS 16.0, *)
extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate {
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController
) {
self.interfaceController = interfaceController
self.interfaceController?.delegate = self

if let serverIdentifier = prefs.string(forKey: carPlayPreferredServerKey),
let selectedServer = Current.servers.server(forServerIdentifier: serverIdentifier) {
setServer(server: selectedServer)
} else if let server = getServer() {
setServer(server: server)
} else {
setEmptyTemplate(interfaceController: interfaceController)
}

updateServerList()

NotificationCenter.default.addObserver(
self,
selector: #selector(updateServerList),
name: HAConnectionState.didTransitionToStateNotification,
object: nil
)

NotificationCenter.default.addObserver(
self,
selector: #selector(updateServerList),
name: HomeAssistantAPI.didConnectNotification,
object: nil
)

/// Observer for servers list changes
Current.servers.add(observer: self)

if Current.servers.all.isEmpty {
showNoServerAlert()
}
}

func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnect interfaceController: CPInterfaceController,
from window: CPWindow
) {
NotificationCenter.default.removeObserver(self)
Current.servers.remove(observer: self)
}
}

// MARK: - ServerObserver

@available(iOS 16.0, *)
extension CarPlaySceneDelegate: ServerObserver {
func serversDidChange(_ serverManager: ServerManager) {
defer {
updateServerListButton()
}

guard let server = getServer(id: serverId) else {
serverId = nil

if let server = getServer() {
setServer(server: server)
} else if interfaceController?.presentedTemplate != nil {
interfaceController?.dismissTemplate(animated: true, completion: nil)
} else {
showNoServerAlert()
}

return
}
setServer(server: server)
}
}

@available(iOS 16.0, *)
extension CarPlaySceneDelegate: CPInterfaceControllerDelegate {
func templateWillDisappear(_ aTemplate: CPTemplate, animated: Bool) {
domainsListTemplate?.templateWillDisappear(template: aTemplate)
}

func templateWillAppear(_ aTemplate: CPTemplate, animated: Bool) {
domainsListTemplate?.templateWillAppear(template: aTemplate)
}
}

protocol CarPlayTemplateProvider {
var template: CPTemplate { get set }
func templateWillDisappear(template: CPTemplate)
func templateWillAppear(template: CPTemplate)
}
8 changes: 7 additions & 1 deletion Sources/App/Scenes/SceneActivity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ enum SceneActivity: CaseIterable {
case webView
case settings
case about
case carPlay

init(activityIdentifier: String) {
self = Self.allCases.first(where: { $0.activityIdentifier == activityIdentifier }) ?? .webView
Expand All @@ -22,6 +23,7 @@ enum SceneActivity: CaseIterable {
case .settings: return "ha.settings"
case .webView: return "ha.webview"
case .about: return "ha.about"
case .carPlay: return "ha.carPlay"
}
}

Expand All @@ -30,10 +32,14 @@ enum SceneActivity: CaseIterable {
case .webView: return "WebView"
case .settings: return "Settings"
case .about: return "About"
case .carPlay: return "CarPlay"
}
}

var configuration: UISceneConfiguration {
.init(name: configurationName, sessionRole: .windowApplication)
switch self {
case .webView, .settings, .about: return .init(name: configurationName, sessionRole: .windowApplication)
case .carPlay: return .init(name: configurationName, sessionRole: .carTemplateApplication)
}
}
}
Loading

0 comments on commit 14081d7

Please sign in to comment.