import Alamofire import CoreLocation import Foundation import HAKit import Intents import ObjectMapper import PromiseKit import RealmSwift #if canImport(UIKit) import UIKit #else import AppKit #endif import Version #if os(iOS) import Reachability #endif public class HomeAssistantAPI { public enum APIError: Error, Equatable { case managerNotAvailable case invalidResponse case cantBuildURL case notConfigured case updateNotPossible case mobileAppComponentNotLoaded case mustUpgradeHomeAssistant(current: Version, minimum: Version) case noAPIAvailable case unknown } struct TokenFetchFailure: LocalizedError { let underlyingType: String let shouldDisconnectPermanently: Bool var errorDescription: String? { "Token fetch failed (\(underlyingType))" } } static func tokenFetchFailure(from error: Error) -> TokenFetchFailure { let errorType = String(reflecting: type(of: error)) let errorDescription = String(describing: error) let underlyingInfo = "\(errorType): \(errorDescription)" return TokenFetchFailure( underlyingType: underlyingInfo, shouldDisconnectPermanently: error.authenticationAPIError?.shouldRequireReauthentication == true ) } public static let didConnectNotification = Notification.Name(rawValue: "HomeAssistantAPIConnected") public private(set) var manager: Alamofire.Session! public static let unauthenticatedManager: Alamofire.Session = configureSessionManager() public let tokenManager: TokenManager public var server: Server public internal(set) var connection: HAConnection public static var clientVersionDescription: String { "\(AppConstants.version) (\(AppConstants.build))" } public static var userAgent: String { // This matches Alamofire's generated string, for consistency with the past let bundle = AppConstants.BundleID let appVersion = AppConstants.version let appBuild = AppConstants.build let osNameVersion: String = { let version = ProcessInfo.processInfo.operatingSystemVersion let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" let notCircularReferenceWrapper = DeviceWrapper() let osName = notCircularReferenceWrapper.systemName() return "\(osName) \(versionString)" }() return "Home Assistant/\(appVersion) (\(bundle); build:\(appBuild); \(osNameVersion))" } /// "Mobile/BUILD_NUMBER" is what CodeMirror sniffs for to decide iOS or not; other things likely look for Safari public static var applicationNameForUserAgent: String { HomeAssistantAPI.userAgent + " Mobile/HomeAssistant, like Safari" } /// Initialize an API object with an authenticated tokenManager. public init(server: Server, urlConfig: URLSessionConfiguration = .default) { self.server = server let tokenManager = TokenManager(server: server) self.tokenManager = tokenManager #if !os(watchOS) // Create URLSession for HAKit REST API calls with certificate handling Current.Log.info("[mTLS] Creating HAKit URLSession for server: \(server.info.name)") Current.Log.info("[mTLS] Has client certificate: \(server.info.connection.clientCertificate != nil)") Current.Log.info("[mTLS] Has security exceptions: \(server.info.connection.securityExceptions.hasExceptions)") let hakitURLSession: URLSession if server.info.connection.clientCertificate != nil || server.info.connection.securityExceptions.hasExceptions { // Use HAKit's certificate provider protocol Current.Log.info("[mTLS] Using HAKit certificate provider") let certificateProvider = HomeAssistantCertificateProvider(server: server) let delegate = HAURLSessionDelegate(certificateProvider: certificateProvider) let configuration = URLSessionConfiguration.ephemeral hakitURLSession = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) } else { Current.Log.info("[mTLS] Using default URLSession for HAKit") hakitURLSession = URLSession(configuration: .ephemeral) } #else let hakitURLSession = URLSession(configuration: .ephemeral) #endif let underlyingConnection = HAKit.connection( configuration: .init( connectionInfo: { do { if let activeURL = server.info.connection.activeURL() { #if !os(watchOS) // Prepare client identity (SecIdentity) for mTLS if configured let clientIdentityProvider: HAConnectionInfo.ClientIdentityProvider? if let clientCert = server.info.connection.clientCertificate { clientIdentityProvider = { try? ClientCertificateManager.shared.retrieveIdentity(for: clientCert) } } else { clientIdentityProvider = nil } return try .init( url: activeURL, userAgent: HomeAssistantAPI.userAgent, evaluateCertificate: { secTrust, completion in completion( Swift.Result { try server.info.connection.securityExceptions.evaluate(secTrust) } ) }, clientIdentity: clientIdentityProvider ) #else return try .init( url: activeURL, userAgent: HomeAssistantAPI.userAgent, evaluateCertificate: { secTrust, completion in completion( Swift.Result { try server.info.connection.securityExceptions.evaluate(secTrust) } ) } ) #endif } else { Current.clientEventStore.addEvent(.init( text: "No active URL available to interact with API, please check if you have internal or external URL available, for internal URL you need to specify your network SSID otherwise for security reasons it won't be available.", type: .networkRequest )) Current.Log.error("activeURL was not available when HAAPI called initializer") return nil } } catch { Current.Log.error("couldn't create connection info: \(error)") return nil } }, fetchAuthToken: { completion in tokenManager.bearerToken.done { completion(.success($0.0)) }.catch { error in let errorType = String(reflecting: type(of: error)) let errorDescription = String(describing: error) Current.Log .error("HAKit token fetch failed with error type: \(errorType), error: \(errorDescription)") completion(.failure(Self.tokenFetchFailure(from: error))) } } ), connectAutomatically: false, urlSession: hakitURLSession ) self.connection = RetryAwareHAConnection(underlying: underlyingConnection) connection.delegate = self #if !os(watchOS) // Use custom delegate that supports client certificates (mTLS) let sessionDelegate: SessionDelegate = server.info.connection.clientCertificate != nil ? ClientCertificateSessionDelegate(server: server) : SessionDelegate() #else let sessionDelegate = SessionDelegate() #endif let manager = HomeAssistantAPI.configureSessionManager( urlConfig: urlConfig, delegate: sessionDelegate, interceptor: newInterceptor(), trustManager: newServerTrustManager() ) self.manager = manager removeOldDownloadDirectory() Current.sensors.register(observer: self) } convenience init?() { if let server = Current.servers.all.first { self.init(server: server, urlConfig: .default) } else { return nil } } private static func configureSessionManager( urlConfig: URLSessionConfiguration = .default, delegate: SessionDelegate = SessionDelegate(), interceptor: Interceptor = .init(), trustManager: ServerTrustManager? = nil ) -> Session { let configuration = urlConfig var headers = configuration.httpAdditionalHeaders ?? [:] headers["User-Agent"] = Self.userAgent configuration.httpAdditionalHeaders = headers return Alamofire.Session( configuration: configuration, delegate: delegate, interceptor: interceptor, serverTrustManager: trustManager ) } private func newInterceptor() -> Interceptor { .init( adapters: [ ServerRequestAdapter(server: server), ], retriers: [ ], interceptors: [ tokenManager.authenticationInterceptor, RetryPolicy(), ] ) } private func newServerTrustManager() -> ServerTrustManager { CustomServerTrustManager(server: server) } public func VideoStreamer() -> MJPEGStreamer { #if !os(watchOS) let delegate: SessionDelegate = server.info.connection.clientCertificate != nil ? MJPEGCertificateSessionDelegate(server: server) : MJPEGStreamerSessionDelegate() #else let delegate = MJPEGStreamerSessionDelegate() #endif return MJPEGStreamer(manager: HomeAssistantAPI.configureSessionManager( delegate: delegate, interceptor: newInterceptor(), trustManager: newServerTrustManager() )) } public enum ConnectReason { case cold case warm case periodic case background var updateSensorTrigger: LocationUpdateTrigger { switch self { case .cold, .warm, .background: return .Launch case .periodic: return .Periodic } } } static func shouldAttemptAutomaticWebSocketConnect(for state: HAConnectionState) -> Bool { switch state { case .disconnected(reason: .disconnected): return true case .disconnected(reason: .waitingToReconnect), .disconnected(reason: .rejected), .connecting, .authenticating, .ready: return false } } public func connectWebSocketIfNeeded() { let state = connection.state guard Self.shouldAttemptAutomaticWebSocketConnect(for: state) else { Current.Log.info("skipping automatic websocket connect while state is \(state)") return } connection.connect() } public func Connect(reason: ConnectReason) -> Promise { Current.Log.info("running connect for \(reason)") // websocket connectWebSocketIfNeeded() return firstly { () -> Promise in #if !os(macOS) guard !Current.isAppExtension else { Current.Log.info("skipping registration changes in extension") return Promise.value(()) } #endif return updateRegistration().asVoid().recover { [self] error -> Promise in switch error as? WebhookError { case .unmappableValue, .unexpectedType, .unacceptableStatusCode(404), .unacceptableStatusCode(410): // cloudhook will send a 404 for deleted // ha directly will send a 200 with an empty body for deleted let message = "Integration is missing; registering." Current.clientEventStore .addEvent(ClientEvent(text: message, type: .networkRequest, payload: [ "error": String(describing: error), ])) return register() case .unregisteredIdentifier, .unacceptableStatusCode, .replaced, .none: // not a WebhookError, or not one we think requires reintegration Current.Log.info("not re-registering, but failed to update registration: \(error)") throw error } } }.then { [self] () -> Promise in var promises: [Promise] = [] #if !os(macOS) if !Current.isAppExtension { promises.append(getConfig()) promises.append(Current.modelManager.fetch(apis: [self])) promises.append(updateComplications(passively: false).asVoid()) } #else promises.append(getConfig()) promises.append(Current.modelManager.fetch(apis: [self])) promises.append(updateComplications(passively: false).asVoid()) #endif promises.append(UpdateSensors(trigger: reason.updateSensorTrigger).asVoid()) return when(fulfilled: promises).asVoid() }.get { _ in NotificationCenter.default.post( name: Self.didConnectNotification, object: nil, userInfo: nil ) } } public func CreateEvent(eventType: String, eventData: [String: Any]) -> Promise { let intent = FireEventIntent(eventName: eventType, payload: eventData) INInteraction(intent: intent, response: nil).donate(completion: nil) return Current.webhooks.sendEphemeral( server: server, request: .init(type: "fire_event", data: [ "event_type": eventType, "event_data": eventData, ]) ) } public func temporaryDownloadFileURL(appropriateFor downloadingURL: URL? = nil) -> URL? { let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) // using a random file name so we always have one, see https://github.com/home-assistant/iOS/issues/1068 .appendingPathComponent(UUID().uuidString, isDirectory: false) if let downloadingURL { return url.appendingPathExtension(downloadingURL.pathExtension) } else { return url } } private func removeOldDownloadDirectory() { let fileManager = FileManager.default if let downloadDataDir = fileManager.containerURL( forSecurityApplicationGroupIdentifier: AppConstants.AppGroupID )?.appendingPathComponent("downloadedData", isDirectory: true) { try? fileManager.removeItem(at: downloadDataDir) } } public func DownloadDataAt(url: URL, needsAuth: Bool) -> Promise { Promise { seal in var finalURL = url let dataManager: Alamofire.Session = needsAuth ? self.manager : Self.unauthenticatedManager if needsAuth { guard let activeURL = server.info.connection.activeURL() else { seal.reject(ServerConnectionError.noActiveURL(server.info.name)) return } if !url.absoluteString.hasPrefix(activeURL.absoluteString) { Current.Log.verbose("URL does not contain base URL, prepending base URL to \(url.absoluteString)") finalURL = activeURL.appendingPathComponent(url.absoluteString) } Current.Log.verbose("Data download needs auth!") } guard let downloadPath = temporaryDownloadFileURL(appropriateFor: finalURL) else { Current.Log.error("Unable to get download path!") seal.reject(APIError.cantBuildURL) return } let destination: DownloadRequest.Destination = { _, _ in (downloadPath, [.removePreviousFile, .createIntermediateDirectories]) } dataManager.download(finalURL, to: destination).validate().responseData { downloadResponse in switch downloadResponse.result { case .success: seal.fulfill(downloadResponse.fileURL!) case let .failure(error): seal.reject(error) } } } } public func getConfig() -> Promise { let promise: Promise = Current.webhooks.sendEphemeral( server: server, request: .init(type: "get_config", data: [:]) ) return promise.done { [self] config in server.update { serverInfo in serverInfo.connection.cloudhookURL = config.CloudhookURL serverInfo.connection.set(address: config.RemoteUIURL, for: .remoteUI) serverInfo.remoteName = config.LocationName ?? ServerInfo.defaultName serverInfo.hassDeviceId = config.hassDeviceId if let version = try? Version(hassVersion: config.Version) { serverInfo.version = version } } Current.crashReporter.setUserProperty(value: config.Version, name: "HA_Version") } } public func GetLogbook() -> Promise<[LogbookEntry]> { request(path: "logbook", callingFunctionName: "\(#function)") } public func CallService( domain: String, service: String, serviceData: [String: Any], triggerSource: AppTriggerSource, shouldLog: Bool = true ) -> Promise { let intent = CallServiceIntent(domain: domain, service: service, payload: serviceData) INInteraction(intent: intent, response: nil).donate(completion: nil) return Current.webhooks.send( identifier: .serviceCall, server: server, request: .init(type: "call_service", data: [ "domain": domain, "service": service, "service_data": serviceData, ]) ) } /// Performs a `call_service` over the websocket and returns the action's response. /// /// Unlike `CallService` (which delivers via webhook and discards any response), this awaits the /// websocket result so actions that support a response (`SupportsResponse.OPTIONAL` / `.ONLY`) /// can surface their data. Set `returnResponse` only for actions that support a response. public func callServiceWithResponse( domain: String, service: String, serviceData: [String: Any], returnResponse: Bool ) -> Promise { let intent = CallServiceIntent(domain: domain, service: service, payload: serviceData) INInteraction(intent: intent, response: nil).donate(completion: nil) return connection.send(.callService( domain: domain, service: service, serviceData: serviceData, returnResponse: returnResponse )).promise } public func turnOnScript(scriptEntityId: String, triggerSource: AppTriggerSource) -> Promise { CallService(domain: Domain.script.rawValue, service: Service.turnOn.rawValue, serviceData: [ "entity_id": scriptEntityId, ], triggerSource: triggerSource) } public func getCameraSnapshot(cameraEntityID: String) -> Promise { Promise { seal in guard let queryUrl = server.info.connection.activeAPIURL()? .appendingPathComponent("camera_proxy/\(cameraEntityID)") else { seal.reject(ServerConnectionError.noActiveURL(server.info.name)) return } _ = manager.request(queryUrl) .validate() .responseData { response in switch response.result { case let .success(data): if let image = UIImage(data: data) { seal.fulfill(image) } case let .failure(error): Current.Log.error("Error when attemping to GetCameraImage(): \(error)") seal.reject(error) } } } } public func register() -> Promise { request( path: "mobile_app/registrations", callingFunctionName: "\(#function)", method: .post, parameters: buildMobileAppRegistration(), encoding: JSONEncoding.default ).recover { error -> Promise in if case AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: 404)) = error { throw APIError.mobileAppComponentNotLoaded } throw error }.done { [server] (resp: MobileAppRegistrationResponse) in Current.Log.verbose("Registration response \(resp)") server.update { server in server.connection.set(address: resp.RemoteUIURL, for: .remoteUI) server.connection.cloudhookURL = resp.CloudhookURL server.connection.webhookID = resp.WebhookID server.connection.webhookSecret = resp.WebhookSecret } } } public func updateRegistration() -> Promise { Current.webhooks.sendEphemeral( server: server, request: .init( type: "update_registration", data: buildMobileAppUpdateRegistration() ) ) } public func GetMobileAppConfig() -> Promise { firstly { () -> Promise in if server.info.version < .mobileAppConfig { let old: Promise = requestImmutable( path: "ios/push", callingFunctionName: "\(#function)" ) return old.map { MobileAppConfig(push: $0) } } else { return requestImmutable(path: "ios/config", callingFunctionName: "\(#function)") } }.recover { error -> Promise in if case AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: 404)) = error { Current.Log.info("ios component is not loaded; pretending there's no push config") return .value(.init()) } throw error } } public func StreamCamera(entityId: String) -> Promise { Current.webhooks.sendEphemeral( server: server, request: .init( type: "stream_camera", data: ["camera_entity_id": entityId] ) ) } private func buildMobileAppRegistration() -> [String: Any] { let ident = mobileAppRegistrationRequestModel() var json = Mapper().toJSON(ident) if server.info.version < .canSendDeviceID { // device_id was added in 0.104, but prior it would error for unknown keys json.removeValue(forKey: "device_id") } return json } private func mobileAppRegistrationRequestModel() -> MobileAppRegistrationRequest { with(MobileAppRegistrationRequest()) { if let pushID = Current.settingsStore.pushID { var appData: [String: Any] = [ "push_url": AppConstants.Firebase.pushURLString, "push_token": pushID, ] #if os(iOS) && !targetEnvironment(macCatalyst) if #available(iOS 17.2, *) { // Push-to-start token (stored in Keychain at launch, updated via stream). // The relay server uses this token to start a Live Activity entirely via APNs. if let pushToStartToken = LiveActivityRegistry.storedPushToStartToken { appData["live_activity_token"] = pushToStartToken appData["live_activity_push_to_start_apns_environment"] = Current.apnsEnvironment } } #endif $0.AppData = appData } $0.AppIdentifier = AppConstants.BundleID $0.AppName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String $0.AppVersion = HomeAssistantAPI.clientVersionDescription $0.DeviceID = Current.settingsStore.integrationDeviceID $0.DeviceName = server.info.setting(for: .overrideDeviceName) ?? Current.device.deviceName() $0.Manufacturer = "Apple" $0.Model = Current.device.systemModel() $0.OSName = Current.device.systemName() $0.OSVersion = Current.device.systemVersion() $0.SupportsEncryption = true } } private func buildMobileAppUpdateRegistration() -> [String: Any] { let registerRequest = mobileAppRegistrationRequestModel() let ident = with(MobileAppUpdateRegistrationRequest()) { $0.AppData = registerRequest.AppData $0.AppVersion = registerRequest.AppVersion $0.DeviceName = registerRequest.DeviceName $0.Manufacturer = registerRequest.Manufacturer $0.Model = registerRequest.Model $0.OSVersion = registerRequest.OSVersion } return Mapper().toJSON(ident) } public func SubmitLocation( updateType: LocationUpdateTrigger, location rawLocation: CLLocation?, zone: RLMZone? ) -> Promise { let update: WebhookUpdateLocation let location: CLLocation? let supportsInZones = server.info.version >= .inZonesOnLocationUpdate let localMetadata = WebhookResponseLocation.localMetdata( trigger: updateType, zone: zone ) switch server.info.setting(for: .locationPrivacy) { case .exact: update = .init(trigger: updateType, location: rawLocation, zone: zone) location = rawLocation case .zoneOnly: let inZones = zones(for: updateType, location: rawLocation, fallbackZone: zone) if updateType == .BeaconRegionEnter || rawLocation != nil { update = .init( trigger: updateType, usingNameOf: locationNameZone( for: updateType, from: inZones, fallbackZone: zone, supportsInZones: supportsInZones ), inZones: supportsInZones ? inZones : nil ) } else { update = .init(trigger: updateType) } location = nil case .never: update = .init(trigger: updateType) location = nil } return firstly { let realm = Current.realm() return when(resolved: realm.reentrantWrite { let accuracyAuthorization: CLAccuracyAuthorization = CLLocationManager().accuracyAuthorization realm.add(LocationHistoryEntry( updateType: updateType, location: location, zone: zone, accuracyAuthorization: accuracyAuthorization, payload: update.toJSONString(prettyPrint: false) ?? "(unknown)" )) }).asVoid() }.map { () -> [String: Any] in let payloadDict = Mapper().toJSON(update) Current.Log.info("Location update payload: \(payloadDict)") return payloadDict }.then { [self] payload in when( resolved: UpdateSensors(trigger: updateType, location: location).asVoid(), Current.webhooks.send( identifier: .location, server: server, request: .init( type: "update_location", data: payload, localMetadata: localMetadata ) ) ) }.asVoid() } private func zones( for updateType: LocationUpdateTrigger, location rawLocation: CLLocation?, fallbackZone zone: RLMZone? ) -> [RLMZone] { if updateType == .BeaconRegionEnter { return zone.flatMap { $0.TrackingEnabled ? [$0] : nil } ?? [] } else if let rawLocation { return RLMZone.zones(of: rawLocation, in: server) } else { return [] } } private func locationNameZone( for updateType: LocationUpdateTrigger, from zones: [RLMZone], fallbackZone zone: RLMZone?, supportsInZones: Bool ) -> RLMZone? { if supportsInZones { return zones.first { !$0.isPassive } } else if updateType == .BeaconRegionEnter { return zone } else { return zones.first } } public var sharedEventDeviceInfo: [String: String] { [ "sourceDevicePermanentID": AppConstants.PermanentID, "sourceDeviceName": server.info.setting(for: .overrideDeviceName) ?? Current.device.deviceName(), "sourceDeviceID": Current.settingsStore.deviceID, ] } public func legacyNotificationActionEvent( identifier: String, category: String?, actionData: Any?, textInput: String? ) -> (eventType: String, eventData: [String: Any]) { var eventData: [String: Any] = sharedEventDeviceInfo eventData["actionName"] = identifier if let category { eventData["categoryName"] = category } if let actionData { eventData["action_data"] = actionData } if let textInput { eventData["response_info"] = textInput eventData["textInput"] = textInput } return (eventType: "ios.notification_action_fired", eventData: eventData) } public func mobileAppNotificationActionEvent( identifier: String, category: String?, actionData: Any?, textInput: String? ) -> (eventType: String, eventData: [String: Any]) { var eventData = [String: Any]() eventData["action"] = identifier if let actionData { eventData["action_data"] = actionData } if let textInput { eventData["reply_text"] = textInput } return (eventType: "mobile_app_notification_action", eventData: eventData) } public func tagEvent( tagPath: String ) -> (eventType: String, eventData: [String: String]) { var eventData: [String: String] = sharedEventDeviceInfo eventData["tag_id"] = tagPath if server.info.version < .tagWebhookAvailable { eventData["device_id"] = Current.settingsStore.integrationDeviceID } return (eventType: "tag_scanned", eventData: eventData) } @available(watchOS, unavailable) public func zoneStateEvent( region: CLRegion, state: CLRegionState, zone: RLMZone ) -> (eventType: String, eventData: [String: Any]) { var eventData: [String: Any] = sharedEventDeviceInfo eventData["zone"] = zone.entityId if region.identifier.contains("@"), let subId = region.identifier.split(separator: "@").last { eventData["multi_region_zone_id"] = String(subId) } if state == .inside { return (eventType: "ios.zone_entered", eventData: eventData) } else { return (eventType: "ios.zone_exited", eventData: eventData) } } public func shareEvent( entered: String, url: URL?, text: String? ) -> (eventType: String, eventData: [String: String]) { var eventData = sharedEventDeviceInfo eventData["entered"] = entered eventData["url"] = url?.absoluteString eventData["text"] = text return ( eventType: "mobile_app.share", eventData: eventData ) } public struct PushActionInfo: ImmutableMappable { public var identifier: String public var category: String? public var textInput: String? public var actionData: Any? public init?(response: UNNotificationResponse) { guard response.actionIdentifier != UNNotificationDefaultActionIdentifier else { return nil } self.identifier = UNNotificationContent.uncombinedAction(from: response.actionIdentifier) self.category = response.notification.request.content.categoryIdentifier self.actionData = response.notification.request.content.userInfo["homeassistant"] self.textInput = (response as? UNTextInputNotificationResponse)?.userText } public init(map: ObjectMapper.Map) throws { self.identifier = try map.value("identifier") self.category = try? map.value("category") self.textInput = try? map.value("textInput") self.actionData = try? map.value("actionData") } public func mapping(map: ObjectMapper.Map) { identifier >>> map["identifier"] category >>> map["category"] textInput >>> map["textInput"] actionData >>> map["actionData"] } } public func handlePushAction(for info: PushActionInfo) -> Promise { let actions = [ legacyNotificationActionEvent( identifier: info.identifier, category: info.category, actionData: info.actionData, textInput: info.textInput ), mobileAppNotificationActionEvent( identifier: info.identifier, category: info.category, actionData: info.actionData, textInput: info.textInput ), ] return when(resolved: actions.map { action -> Promise in Current.Log.verbose("Sending action: \(action.eventType) payload: \(action.eventData)") return CreateEvent(eventType: action.eventType, eventData: action.eventData) }).asVoid() } public func executeActionForDomainType(domain: Domain, entityId: String, state: String) -> Promise { var request: HATypedRequest? // 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 { return connection.send(request).promise .map { _ in () } } else { return .value } } public func registerSensors() -> Promise { firstly { Current.sensors.sensors(reason: .registration, server: server).map(\.sensors) }.get { sensors in Current.Log.verbose("Registering sensors \(sensors.map(\.UniqueID))") }.thenMap { [server] sensor in Current.webhooks.send(server: server, request: .init(type: "register_sensor", data: sensor.toJSON())) }.tap { result in Current.Log.info("finished registering sensors: \(result)") }.asVoid() } public func UpdateSensors( trigger: LocationUpdateTrigger, location: CLLocation? = nil ) -> Promise { UpdateSensors(trigger: trigger, limitedTo: nil, location: location) } func UpdateSensors( trigger: LocationUpdateTrigger, limitedTo: [SensorProvider.Type]?, location: CLLocation? = nil ) -> Promise { firstly { Current.sensors.sensors( reason: .trigger(trigger.rawValue), limitedTo: limitedTo, location: location, server: server ) }.map { sensorResponse -> (SensorResponse, [[String: Any]]) in Current.Log.info("updating sensors \(sensorResponse.sensors.map { $0.UniqueID ?? "unknown" })") let mapper = Mapper( context: WebhookSensorContext(update: true), shouldIncludeNilValues: false ) return (sensorResponse, mapper.toJSONArray(sensorResponse.sensors)) }.then { [server] _, payload -> Promise in if payload.isEmpty { Current.Log.info("skipping network request for unchanged sensor update") return .value(()) } else { return Current.webhooks.send( identifier: .updateSensors, server: server, request: .init(type: "update_sensor_states", data: payload) ) } } } #if os(iOS) public enum ManualUpdateType { case userRequested case appOpened case programmatic var allowsTemporaryAccess: Bool { switch self { case .userRequested, .appOpened: return true case .programmatic: return false } } } public static func manuallyUpdate( applicationState: UIApplication.State, type: ManualUpdateType ) -> Promise { Current.backgroundTask(withName: BackgroundTask.manualLocationUpdate.rawValue) { _ in firstly { () -> Guarantee in Guarantee { seal in let locationManager = CLLocationManager() guard locationManager.accuracyAuthorization != .fullAccuracy else { // already have full accuracy, don't need to request return seal(()) } guard type.allowsTemporaryAccess else { return seal(()) } Current.Log.info("requesting full accuracy for manual update") locationManager.requestTemporaryFullAccuracyAuthorization( withPurposeKey: "TemporaryFullAccuracyReasonManualUpdate" ) { error in Current.Log.info("got temporary full accuracy result: \(String(describing: error))") withExtendedLifetime(locationManager) { seal(()) } } } }.then { () -> Promise in func updateWithoutLocation() -> Promise { when(fulfilled: Current.apis.map { $0.UpdateSensors(trigger: .Manual) }) } if Current.settingsStore.isLocationEnabled(for: applicationState) { return firstly { Current.location.oneShotLocation(.Manual, nil) }.then { location in when(fulfilled: Current.apis.map { api in api.SubmitLocation(updateType: .Manual, location: location, zone: nil) }).asVoid() }.recover { error -> Promise in if error is CLError || error is OneShotError { Current.Log.info("couldn't get location, sending remaining sensor data") return updateWithoutLocation() } else { throw error } } } else { return updateWithoutLocation() } } } } #endif private final class ProfilePictureCancellable: HACancellable { private(set) var isCancelled = false private var cancellables = [HACancellable]() private var downloadRequest: Request? func add(_ cancellable: HACancellable) { if isCancelled { cancellable.cancel() } else { cancellables.append(cancellable) } } func setDownloadRequest(_ request: Request) { if isCancelled { request.cancel() } else { downloadRequest = request } } func cancel() { guard !isCancelled else { return } isCancelled = true cancellables.forEach { $0.cancel() } downloadRequest?.cancel() cancellables.removeAll() downloadRequest = nil } } @discardableResult public func currentUser(completion: @escaping (HAResponseCurrentUser?) -> Void) -> HACancellable { connection.send(HATypedRequest.fetchCurrentUser()) { result in switch result { case let .success(user): completion(user) case let .failure(error): Current.Log.error("Failed to retrieve current user: \(error)") completion(nil) } } } @discardableResult public func profilePictureURL( for user: HAResponseCurrentUser, completion: @escaping (URL?) -> Void ) -> HACancellable { connection.send(HATypedRequest<[HAEntity]>.fetchStates()) { [weak self] result in switch result { case let .success(states): guard let person = states.first(where: { $0.attributes["user_id"] as? String == user.id }) else { Current.Log.error("Profile picture: No person found for user \(user.id)") completion(nil) return } guard let path = person.attributes["entity_picture"] as? String else { Current.Log.error("Profile picture: Missing URL for user entity picture, user id \(user.id)") completion(nil) return } guard let url = self?.resolvedProfilePictureURL(from: path) else { Current.Log.error("Profile picture: Invalid URL for user entity picture, user id \(user.id)") completion(nil) return } completion(url) case let .failure(error): Current.Log.error("Failed to retrieve states for profile picture: \(error)") completion(nil) } } } @discardableResult public func profilePictureURL(completion: @escaping (URL?) -> Void) -> HACancellable { let cancellable = ProfilePictureCancellable() cancellable.add(currentUser { [weak self] user in guard !cancellable.isCancelled else { return } guard let self, let user else { completion(nil) return } cancellable.add(profilePictureURL(for: user) { url in guard !cancellable.isCancelled else { return } completion(url) }) }) return cancellable } @discardableResult public func profilePicture( for user: HAResponseCurrentUser, completion: @escaping (UIImage?) -> Void ) -> HACancellable { let cancellable = ProfilePictureCancellable() cancellable.add(profilePictureURL(for: user) { [weak self] url in guard !cancellable.isCancelled else { return } guard let self, let url else { completion(nil) return } let request = manager.download(url).validate() cancellable.setDownloadRequest(request) request.responseData { response in guard !cancellable.isCancelled else { return } switch response.result { case let .success(data): completion(UIImage(data: data)) case let .failure(error): Current.Log.error("Failed to download profile picture: \(error)") completion(nil) } } }) return cancellable } @discardableResult public func profilePicture(completion: @escaping (UIImage?) -> Void) -> HACancellable { let cancellable = ProfilePictureCancellable() cancellable.add(currentUser { [weak self] user in guard !cancellable.isCancelled else { return } guard let self, let user else { completion(nil) return } cancellable.add(profilePicture(for: user) { image in guard !cancellable.isCancelled else { return } completion(image) }) }) return cancellable } private func resolvedProfilePictureURL(from path: String) -> URL? { guard let activeURL = server.info.connection.activeURL() else { return nil } guard let url = URL(string: path, relativeTo: activeURL)?.absoluteURL else { return nil } guard url.hasSameOrigin(as: activeURL) else { return nil } return url } } private extension URL { func hasSameOrigin(as other: URL) -> Bool { guard let scheme = scheme?.lowercased(), let otherScheme = other.scheme?.lowercased(), let host = host?.lowercased(), let otherHost = other.host?.lowercased() else { return false } return scheme == otherScheme && host == otherHost && normalizedOriginPort == other.normalizedOriginPort } private var normalizedOriginPort: Int? { if let port { return port } switch scheme?.lowercased() { case "http": return 80 case "https": return 443 default: return nil } } } #if !os(watchOS) /// Certificate provider implementation for Home Assistant servers private class HomeAssistantCertificateProvider: HACertificateProvider { private let server: Server init(server: Server) { self.server = server } func provideClientCertificate( for challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { Current.Log.info("[mTLS HAKit] Client certificate requested") guard let clientCertificate = server.info.connection.clientCertificate else { Current.Log.warning("[mTLS HAKit] Client certificate requested but none configured") completionHandler(.performDefaultHandling, nil) return } do { let credential = try ClientCertificateManager.shared.urlCredential(for: clientCertificate) Current.Log.info("[mTLS HAKit] Using client certificate: \(clientCertificate.displayName)") completionHandler(.useCredential, credential) } catch { Current.Log.error("[mTLS HAKit] Failed to get credential: \(error)") completionHandler(.cancelAuthenticationChallenge, nil) } } func evaluateServerTrust( _ serverTrust: SecTrust, forHost host: String, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void ) { Current.Log.info("[mTLS HAKit] Evaluating server trust for: \(host)") do { try server.info.connection.securityExceptions.evaluate(serverTrust) Current.Log.info("[mTLS HAKit] Server trust validation successful") let credential = URLCredential(trust: serverTrust) completionHandler(.useCredential, credential) } catch { Current.Log.error("[mTLS HAKit] Server trust validation failed: \(error)") completionHandler(.rejectProtectionSpace, nil) } } } #endif /// Prevents pending websocket work from resetting HAKit's reconnect backoff. final class RetryAwareHAConnection: HAConnection { private let underlying: HAConnection weak var delegate: HAConnectionDelegate? var configuration: HAConnectionConfiguration { get { underlying.configuration } set { underlying.configuration = newValue } } var state: HAConnectionState { underlying.state } var caches: HACachesContainer { underlying.caches } var callbackQueue: DispatchQueue { get { underlying.callbackQueue } set { underlying.callbackQueue = newValue } } init(underlying: HAConnection) { self.underlying = underlying underlying.delegate = self } func connect() { underlying.connect() } func disconnect() { underlying.disconnect() } @discardableResult func send( _ request: HARequest, completion: @escaping RequestCompletion ) -> HACancellable { connectIfNeeded(for: request) return underlying.send(request, completion: completion) } @discardableResult func send( _ request: HATypedRequest, completion: @escaping (Swift.Result) -> Void ) -> HACancellable { connectIfNeeded(for: request.request) return underlying.send(request, completion: completion) } @discardableResult func subscribe( to request: HARequest, handler: @escaping SubscriptionHandler ) -> HACancellable { connectIfNeeded(for: request) return underlying.subscribe(to: request, handler: handler) } @discardableResult func subscribe( to request: HARequest, initiated: @escaping SubscriptionInitiatedHandler, handler: @escaping SubscriptionHandler ) -> HACancellable { connectIfNeeded(for: request) return underlying.subscribe(to: request, initiated: initiated, handler: handler) } @discardableResult func subscribe( to request: HATypedSubscription, handler: @escaping (HACancellable, T) -> Void ) -> HACancellable { connectIfNeeded(for: request.request) return underlying.subscribe(to: request, handler: handler) } @discardableResult func subscribe( to request: HATypedSubscription, initiated: @escaping SubscriptionInitiatedHandler, handler: @escaping (HACancellable, T) -> Void ) -> HACancellable { connectIfNeeded(for: request.request) return underlying.subscribe(to: request, initiated: initiated, handler: handler) } private func connectIfNeeded(for request: HARequest) { switch request.type { case .rest: return case .webSocket, .sttData: break } guard HomeAssistantAPI.shouldAttemptAutomaticWebSocketConnect(for: underlying.state) else { return } underlying.connect() } } extension RetryAwareHAConnection: HAConnectionDelegate { func connection(_ connection: HAConnection, didTransitionTo state: HAConnectionState) { delegate?.connection(self, didTransitionTo: state) NotificationCenter.default.post( name: HAConnectionState.didTransitionToStateNotification, object: self ) } } extension HomeAssistantAPI.APIError: LocalizedError { public var errorDescription: String? { switch self { case .managerNotAvailable: return L10n.HaApi.ApiError.managerNotAvailable case .invalidResponse: return L10n.HaApi.ApiError.invalidResponse case .cantBuildURL: return L10n.HaApi.ApiError.cantBuildUrl case .notConfigured: return L10n.HaApi.ApiError.notConfigured case .updateNotPossible: return L10n.HaApi.ApiError.updateNotPossible case .mobileAppComponentNotLoaded: return L10n.HaApi.ApiError.mobileAppComponentNotLoaded case let .mustUpgradeHomeAssistant(current: current, minimum: minimum): return L10n.HaApi.ApiError.mustUpgradeHomeAssistant( current.description, minimum.description ) case .noAPIAvailable: return L10n.HaApi.ApiError.noAvailableApi case .unknown: return L10n.HaApi.ApiError.unknown } } } extension HomeAssistantAPI: SensorObserver { public func sensorContainer( _ container: SensorContainer, didSignalForUpdateBecause reason: SensorContainerUpdateReason, lastUpdate: SensorObserverUpdate? ) { Current.backgroundTask(withName: BackgroundTask.signaledUpdateSensors.rawValue) { _ in UpdateSensors(trigger: .Signaled) }.cauterize() } public func sensorContainer(_ container: SensorContainer, didUpdate update: SensorObserverUpdate) { // we don't do anything for this } } extension HomeAssistantAPI: HAConnectionDelegate { public func connection(_ connection: HAConnection, didTransitionTo state: HAConnectionState) { guard case let .disconnected(reason: .waitingToReconnect(lastError: error, atLatest: _, retryCount: _)) = state, let tokenFetchFailure = error as? TokenFetchFailure, tokenFetchFailure.shouldDisconnectPermanently else { return } Current.Log.info("stopping websocket reconnects after fatal auth token fetch failure") connection.disconnect() } } #if os(macOS) // HAKit's PromiseKit extensions (the HAKit/PromiseKit subspec) are not linked into the native // macOS target; replicate the same bridging API so shared call sites compile unchanged. extension HAConnection { /// Send a request, wrapped in a Promise. /// - SeeAlso: `HAConnection.send(_:completion:)` func send(_ request: HARequest) -> (promise: Promise, cancel: () -> Void) { let (promise, seal) = Promise.pending() let token = send(request, completion: { result in switch result { case let .success(data): seal.fulfill(data) case let .failure(error): seal.reject(error) } }) return (promise: promise, cancel: token.cancel) } /// Send a typed request, wrapped in a Promise. /// - SeeAlso: `HAConnection.send(_:completion:)` func send(_ request: HATypedRequest) -> (promise: Promise, cancel: () -> Void) { let (promise, seal) = Promise.pending() let token = send(request, completion: { result in switch result { case let .success(data): seal.fulfill(data) case let .failure(error): seal.reject(error) } }) return (promise: promise, cancel: token.cancel) } } extension HACache { /// Wrap a once subscription in a Guarantee. /// - SeeAlso: `HACache.once(_:)` func once() -> (promise: Guarantee, cancel: () -> Void) { let (guarantee, seal) = Guarantee.pending() let token = once(seal) return (promise: guarantee, cancel: token.cancel) } } #endif