CarPlay-Demo: Nivellierung auf dem Fahrzeugdisplay

Eigene Build-Konfiguration "CarPlay" samt Schema, damit der Versuchsbau neben
der TestFlight-App stehen kann: eigener Name, eigene Bundle-ID, persoenliches
Team, manuelles Signing. Debug und Release bleiben unberuehrt.

Wichtig dabei: Die Signing-Einstellungen stehen ausschliesslich in den
Target-Konfigurationen. Steht CODE_SIGN_STYLE auf Projektebene, schreibt der
naechste xcodebuild-Lauf alle uebrigen Konfigurationen auf Manual um und loescht
ihr Team – der TestFlight-Build waere damit hin.

Der CarPlay-Code haengt an `CARPLAY_DEMO` und existiert im regulaeren Build
nicht. Die Szene zeichnet in das gelieferte CPWindow; das CPMapTemplate darueber
bleibt leer, weil es auf dem Stellplatz nichts zu tippen gibt. Die Anzeige
sperrt sich oberhalb von 5 km/h selbst – erzwungen, nicht zugesagt.

Die Groessen sind auf die 400x240 Punkte ausgelegt, die CarPlay liefert, nicht
auf die 800x480 Pixel. In Pixeln gedachte Schriftgroessen sind auf dem
Fahrzeugdisplay doppelt zu gross und schneiden den Text ab.

Bekannt: Unter iOS 26.5 stuerzt Apples CarPlayTemplateUIHost beim Aufbau der
Navigationsleiste ab (`_updateShareButtonVisibility` fragt seinen
destinationSharingDelegate nach `vehicleSupportsDestinationSharing`, den dieser
nicht kennt). Beides ist privates API und von der App aus nicht erreichbar;
unter iOS 18 laeuft es. Szene und Root-Template werden vorher in beiden
Faellen akzeptiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fototeddy
2026-09-10 18:35:33 +02:00
co-authored by Claude Opus 5
parent b1ffbe324a
commit ae12d8ede4
10 changed files with 715 additions and 0 deletions
@@ -0,0 +1,91 @@
#if CARPLAY_DEMO
import SwiftUI
/// Die Libelle auf dem Fahrzeugdisplay.
///
/// Quer geteilt: links die Blase, rechts in Worten, was zu tun ist. Beim
/// Rangieren wird abwechselnd auf Display und Umfeld geschaut deshalb steht
/// die Aussage in einem Satz da, statt sich aus zwei Zahlen ergeben zu müssen.
///
/// Die Groessen sind auf die 400 × 240 **Punkte** ausgelegt, die CarPlay
/// liefert nicht auf die 800 × 480 Pixel. Das ist die Falle: In Pixeln
/// gedachte Schriftgroessen sind auf dem Fahrzeugdisplay doppelt zu gross und
/// schneiden den Text ab.
struct CarPlayLevelScreen: View {
@Environment(CarPlayLevelSource.self) private var source
private var state: LevelState {
LevelState(pitch: source.pitch, roll: source.roll)
}
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if CarPlaySpeedGate.shared.isStationary {
levelling
} else {
driving
}
}
.onAppear { CarPlaySpeedGate.shared.start() }
.onDisappear { CarPlaySpeedGate.shared.stop() }
}
private var levelling: some View {
HStack(spacing: 18) {
LevelBubble(pitch: source.pitch, roll: source.roll)
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
let lines = state.shortInstructions
ForEach(lines.isEmpty ? ["Keine Verbindung"] : lines, id: \.self) { line in
Text(line)
.font(.system(size: 20, weight: .semibold, design: .rounded))
.foregroundStyle(state.isLevel ? .green : .white)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
}
HStack(spacing: 16) {
tile("Längs", LevelDirectionFormatting.pitchTile(source.pitch))
tile("Quer", LevelDirectionFormatting.rollTile(source.roll))
}
if source.isSimulated {
// Ohne diesen Hinweis wäre ein Screenshot aus dem Simulator
// eine Behauptung über Messwerte, die es nicht gab.
Text("Simulierte Werte")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.orange)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(16)
}
private var driving: some View {
VStack(spacing: 10) {
Image(systemName: "car.fill")
.font(.system(size: 30))
Text("Nivellierung nur im Stand")
.font(.system(size: 20, weight: .semibold, design: .rounded))
}
.foregroundStyle(.white.opacity(0.7))
}
private func tile(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Text(value)
.font(.system(size: 22, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.white)
}
}
}
#endif
+105
View File
@@ -0,0 +1,105 @@
#if CARPLAY_DEMO
import Foundation
import Observation
/// Woher die CarPlay-Libelle ihre Werte bekommt.
///
/// Im Simulator gibt es kein Bluetooth und damit keinen Neigungsmesser. Für
/// die Demo läuft deshalb ein simulierter Verlauf: ein Fahrzeug, das langsam
/// auf die Keile rollt, gerade wird und wieder abrutscht. Das reicht, um die
/// Anzeige zu beurteilen und für Screenshots, die zeigen, was die Funktion
/// tut.
///
/// Für gezielte Aufnahmen lassen sich feste Werte über die Umgebung setzen:
/// `CARPLAY_DEMO_PITCH` und `CARPLAY_DEMO_ROLL` (in Grad). Dann steht die
/// Blase still und der Screenshot ist reproduzierbar.
///
/// Am Fahrzeug speist stattdessen der `BluetoothManager` über ``apply(_:)``
/// ein; die Demo-Schleife läuft dann nicht.
@Observable
final class CarPlayLevelSource {
static let shared = CarPlayLevelSource()
private(set) var pitch: Double?
private(set) var roll: Double?
/// Ob die Werte simuliert sind. Die Anzeige weist darauf hin ein
/// Screenshot ohne diesen Hinweis wäre gegenüber Apple nicht ehrlich.
private(set) var isSimulated = false
@ObservationIgnored private var timer: Timer?
@ObservationIgnored private var phase: Double = 0
@ObservationIgnored private lazy var isSequenced =
ProcessInfo.processInfo.environment["CARPLAY_DEMO_SEQUENCE"] != nil
private init() {}
/// Zehn Aktualisierungen pro Sekunde schnell genug, dass die Blase der
/// Bewegung des Fahrzeugs folgt statt ihr nachzulaufen.
private static let updateInterval: TimeInterval = 0.1
func start() {
guard timer == nil else { return }
let environment = ProcessInfo.processInfo.environment
if let fixedPitch = environment["CARPLAY_DEMO_PITCH"].flatMap(Double.init),
let fixedRoll = environment["CARPLAY_DEMO_ROLL"].flatMap(Double.init) {
pitch = fixedPitch
roll = fixedRoll
isSimulated = true
return
}
isSimulated = true
let timer = Timer.scheduledTimer(withTimeInterval: Self.updateInterval, repeats: true) { [weak self] _ in
self?.step()
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
func stop() {
timer?.invalidate()
timer = nil
}
/// Echte Messwerte vom Neigungsmesser. Sobald die kommen, ist nichts mehr
/// simuliert.
func apply(_ state: LevelState) {
stop()
isSimulated = false
pitch = state.pitch
roll = state.roll
}
/// Feste Zustände im Wechsel, je sieben Sekunden für Screenshots, die
/// beide Fälle zuverlässig zeigen statt auf den richtigen Moment einer
/// Schwingung zu warten.
private static let sequence: [(pitch: Double, roll: Double)] = [
(1.8, -0.9), // deutlich schief
(0.2, -0.1), // ausgerichtet
]
/// Ein Fahrzeug, das sich über gut zwanzig Sekunden einpendelt und wieder
/// abweicht. Zwei unterschiedlich schnelle Schwingungen, damit Längs- und
/// Querneigung nicht im Gleichtakt laufen das sähe mechanisch aus.
private func step() {
phase += Self.updateInterval
if isSequenced {
// Dritte Phase: die Sperre waehrend der Fahrt.
let step = Int(phase / 7) % (Self.sequence.count + 1)
if step == Self.sequence.count {
CarPlaySpeedGate.shared.setDemoDriving(true)
} else {
CarPlaySpeedGate.shared.setDemoDriving(false)
(pitch, roll) = Self.sequence[step]
}
return
}
pitch = 3.2 * sin(phase / 3.4)
roll = 2.1 * sin(phase / 2.1 + 0.7)
}
}
#endif
@@ -0,0 +1,40 @@
import SwiftUI
/// Zeigt die CarPlay-Ansicht im iPhone-Simulator, in den Maßen, die CarPlay
/// tatsächlich liefert (400 × 240 Punkte, also 800 × 480 Pixel).
///
/// Nur zum Beurteilen des Layouts, solange der CarPlay-Simulator selbst nicht
/// zur Verfügung steht. Ohne `CARPLAY_DEMO_PREVIEW` in der Umgebung passiert
/// nichts, und ohne das `CARPLAY_DEMO`-Flag existiert der Code gar nicht.
extension View {
@ViewBuilder
func carPlayPreviewOverlay() -> some View {
#if CARPLAY_DEMO
if ProcessInfo.processInfo.environment["CARPLAY_DEMO_PREVIEW"] != nil {
CarPlayPreviewHost()
} else {
self
}
#else
self
#endif
}
}
#if CARPLAY_DEMO
struct CarPlayPreviewHost: View {
var body: some View {
// Ohne Beiwerk und exakt zentriert: so laesst sich der Screenshot
// verlustfrei auf den CarPlay-Bereich zuschneiden.
VStack {
CarPlayLevelScreen()
.environment(CarPlayLevelSource.shared)
.frame(width: 400, height: 240)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
.ignoresSafeArea()
.onAppear { CarPlayLevelSource.shared.start() }
}
}
#endif
@@ -0,0 +1,96 @@
#if CARPLAY_DEMO
import CarPlay
import OSLog
import SwiftUI
/// Einstiegspunkt der CarPlay-Szene.
///
/// Mit dem Navigations-Entitlement liefert CarPlay ein eigenes `CPWindow`, in
/// das frei gezeichnet werden darf anders als bei den Template-Kategorien,
/// die nur vorgegebene Bausteine erlauben und höchstens alle zehn Sekunden
/// aktualisieren. Genau deshalb hängt hier die Libelle drin: Beim Rangieren
/// muss sie der Bewegung des Fahrzeugs folgen, nicht ihr hinterherlaufen.
///
/// Das `CPMapTemplate` darüber ist nur die Bedienleiste. Es bleibt bewusst
/// leer: Auf dem Stellplatz gibt es nichts zu tippen, es gibt nur etwas
/// abzulesen.
@objc(CarPlaySceneDelegate)
final class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate, CPMapTemplateDelegate {
private var interfaceController: CPInterfaceController?
/// Ohne eigenes Logging ist beim Debuggen nicht zu unterscheiden, ob die
/// Szene gar nicht verbindet oder ob CarPlay das Template ablehnt.
private static let log = Logger(subsystem: "de.s0.fototeddy.VanControl", category: "CarPlay")
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController,
to window: CPWindow
) {
Self.log.notice("didConnect: Szene verbunden, Fenster \(NSCoder.string(for: window.bounds.size), privacy: .public)")
self.interfaceController = interfaceController
let root = CarPlayLevelScreen()
.environment(CarPlayLevelSource.shared)
let host = UIHostingController(rootView: root)
host.view.backgroundColor = .black
window.rootViewController = host
window.makeKeyAndVisible()
CarPlayLevelSource.shared.start()
// Bewusst nackt konfiguriert: iOS 26 laesst den CarPlayTemplateUIHost
// beim Aufbau der Navigationsleiste ueber den Share-Button stolpern
// (`_updateShareButtonVisibility` -> unrecognized selector), sobald die
// Leiste erzwungen wird. Ohne Buttons und mit der Standard-Automatik
// gibt es nichts zu konfigurieren und auf dem Stellplatz gibt es
// ohnehin nichts zu tippen.
let map = CPMapTemplate()
map.mapButtons = []
map.leadingNavigationBarButtons = []
map.trailingNavigationBarButtons = []
map.hidesButtonsWithNavigationBar = true
map.mapDelegate = self
interfaceController.setRootTemplate(map, animated: false) { success, error in
if let error {
Self.log.error("setRootTemplate abgelehnt: \(error.localizedDescription, privacy: .public)")
} else {
Self.log.notice("setRootTemplate ok: \(success, privacy: .public)")
}
}
}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController,
from window: CPWindow
) {
Self.log.notice("didDisconnect: Szene getrennt")
self.interfaceController = nil
window.rootViewController = nil
CarPlayLevelSource.shared.stop()
}
// MARK: - Zielteilung
// CarPlay bietet seit iOS 26 an, ein Navigationsziel an das Fahrzeug zu
// übergeben. Wir haben keine Ziele aber das Protokoll vollständig zu
// bedienen ist Teil davon, eine korrekte Navigations-App zu sein. Der
// Verdacht dabei: Apples Template-Host löst seinen Sharing-Delegate nur
// dann sauber auf, wenn die App diese Methoden anbietet.
@available(iOS 26.4, *)
func mapTemplate(_ mapTemplate: CPMapTemplate, willShareDestinationFor trip: CPTrip) {
Self.log.notice("willShareDestination die App teilt keine Ziele")
}
@available(iOS 26.4, *)
func mapTemplate(_ mapTemplate: CPMapTemplate, didShareDestinationFor trip: CPTrip) {
Self.log.notice("didShareDestination")
}
@available(iOS 26.4, *)
func mapTemplate(_ mapTemplate: CPMapTemplate, didFailToShareDestinationFor trip: CPTrip, error: Error) {
Self.log.error("didFailToShareDestination: \(error.localizedDescription, privacy: .public)")
}
}
#endif
+77
View File
@@ -0,0 +1,77 @@
#if CARPLAY_DEMO
import CoreLocation
import Observation
/// Sperrt die Libelle, sobald das Fahrzeug schneller als Schrittgeschwindigkeit
/// fährt.
///
/// Ausrichten passiert im Stand oder im Zentimeterbereich davor. Eine Anzeige,
/// die während der Fahrt etwas zu lesen gäbe, wäre Ablenkung ohne Nutzen also
/// gibt es sie dort nicht. Die Grenze ist bewusst technisch erzwungen und nicht
/// bloss eine Zusage im Text.
@Observable
final class CarPlaySpeedGate: NSObject, CLLocationManagerDelegate {
static let shared = CarPlaySpeedGate()
/// Fünf km/h: schneller als Rangieren, langsamer als jede Verkehrssituation,
/// in der jemand aufs Display sehen dürfte.
static let limit: CLLocationSpeed = 5.0 / 3.6
private(set) var isStationary = true
private(set) var speed: CLLocationSpeed?
@ObservationIgnored private let manager = CLLocationManager()
private override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
}
func start() {
// Fuer Screenshots laesst sich die Geschwindigkeit vorgeben (in km/h);
// eine simulierte GPS-Fahrt nur fuer ein Standbild waere unverhaeltnis-
// maessig. Ohne die Variable zaehlt ausschliesslich der echte Sensor.
if let forced = ProcessInfo.processInfo.environment["CARPLAY_DEMO_SPEED"].flatMap(Double.init) {
speed = forced / 3.6
isStationary = forced / 3.6 <= Self.limit
return
}
manager.requestWhenInUseAuthorization()
manager.startUpdatingLocation()
}
/// Nur für die Demo-Sequenz: erzwingt den Fahrzustand, damit sich die
/// Sperre zeigen lässt, ohne eine GPS-Fahrt zu simulieren.
func setDemoDriving(_ driving: Bool) {
speed = driving ? 30 / 3.6 : 0
isStationary = !driving
}
func stop() {
manager.stopUpdatingLocation()
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
// CoreLocation meldet im Stand eine negative Geschwindigkeit: der Wert
// ist dann schlicht unbekannt. Das als "fährt" zu werten, würde die
// Funktion genau dort abschalten, wo sie gebraucht wird auf dem
// Stellplatz, oft unter Bäumen mit schlechtem Empfang.
guard location.speed >= 0 else {
speed = nil
isStationary = true
return
}
speed = location.speed
isStationary = location.speed <= Self.limit
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
speed = nil
isStationary = true
}
}
#endif
+1
View File
@@ -27,6 +27,7 @@ struct VanControlApp: App {
.environment(watch)
.environment(levelActivity)
.task { watch.activate() }
.carPlayPreviewOverlay()
}
.onChange(of: scenePhase) { _, phase in
// Im Hintergrund darf ohne Service-Filter ohnehin nicht gescannt