Files
iOS/Sources/Shared/API/HAAPI.swift
Zac West 2deea8502d End Watch Complication refresh task on network start, not network end (#1192)
Running the latest Watch Extension on my device, I'm seeing a lot of kills like:

> Termination Reason: CAROUSEL, Background App Refresh watchdog transgression. Exhausted wall time allowance of 15.00 seconds.

From the [documentation on `WKApplicationRefreshBackgroundTask`](https://developer.apple.com/documentation/watchkit/wkapplicationrefreshbackgroundtask#), the suggestion is to kick off a background URLSession request and immediately end the app refresh task, allowing the background session code paths to complete the execution. Given we're being killed so often, I'm guessing that part'll work just fine.

Fixes #1191.
2020-10-13 22:05:48 -07:00

829 lines
31 KiB
Swift

//
// HAAPI.swift
// HomeAssistant
//
// Created by Robbie Trencheny on 3/25/16.
// Copyright © 2016 Robbie Trencheny. All rights reserved.
//
import Alamofire
import PromiseKit
import CoreLocation
import Foundation
import KeychainAccess
import ObjectMapper
import RealmSwift
import UserNotifications
import Intents
import Version
import UIKit
#if os(iOS)
import Reachability
#endif
private let keychain = Constants.Keychain
// swiftlint:disable file_length
// swiftlint:disable:next type_body_length
public class HomeAssistantAPI {
public enum APIError: Error {
case managerNotAvailable
case invalidResponse
case cantBuildURL
case notConfigured
case updateNotPossible
case mobileAppComponentNotLoaded
case mustUpgradeHomeAssistant(Version)
case unknown
}
static let minimumRequiredVersion = Version(major: 0, minor: 92, patch: 2)
public static let didConnectNotification = Notification.Name(rawValue: "HomeAssistantAPIConnected")
let prefs = UserDefaults(suiteName: Constants.AppGroupID)!
public static var LoadedComponents = [String]()
public private(set) var manager: Alamofire.SessionManager!
public static var unauthenticatedManager: Alamofire.SessionManager = {
return configureSessionManager()
}()
public var MobileAppComponentLoaded: Bool {
return HomeAssistantAPI.LoadedComponents.contains("mobile_app")
}
var tokenManager: TokenManager?
public func connectionInfo() throws -> ConnectionInfo {
if let connectionInfo = Current.settingsStore.connectionInfo {
return connectionInfo
} else {
throw HomeAssistantAPI.APIError.notConfigured
}
}
public static var clientVersionDescription: String {
"\(Constants.version) (\(Constants.build))"
}
public static var userAgent: String {
// This matches Alamofire's generated string, for consistency with the past
let bundle = Constants.BundleID
let appVersion = Constants.version
let appBuild = Constants.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))"
}
/// Initialize an API object with an authenticated tokenManager.
public init(tokenInfo: TokenInfo, urlConfig: URLSessionConfiguration = .default) {
self.tokenManager = TokenManager(tokenInfo: tokenInfo)
let manager = HomeAssistantAPI.configureSessionManager(urlConfig: urlConfig)
manager.retrier = self.tokenManager
manager.adapter = self.tokenManager
self.manager = manager
removeOldDownloadDirectory()
Current.sensors.register(observer: self)
}
private static func configureSessionManager(urlConfig: URLSessionConfiguration = .default) -> SessionManager {
let configuration = urlConfig
var headers = configuration.httpAdditionalHeaders ?? [:]
headers["User-Agent"] = Self.userAgent
configuration.httpAdditionalHeaders = headers
return Alamofire.SessionManager(configuration: configuration)
}
func authenticatedSessionManager() -> Alamofire.SessionManager? {
guard Current.settingsStore.connectionInfo != nil && Current.settingsStore.tokenInfo != nil else {
return nil
}
let manager = HomeAssistantAPI.configureSessionManager()
manager.retrier = self.tokenManager
manager.adapter = self.tokenManager
return manager
}
private static var sharedAPI: HomeAssistantAPI?
public static func authenticatedAPI(urlConfig: URLSessionConfiguration = .default,
forceInit: Bool = false) -> HomeAssistantAPI? {
if let api = sharedAPI, forceInit == false {
return api
}
guard Current.settingsStore.connectionInfo != nil else {
return nil
}
if let tokenInfo = Current.settingsStore.tokenInfo {
let api = HomeAssistantAPI(tokenInfo: tokenInfo, urlConfig: urlConfig)
self.sharedAPI = api
}
return self.sharedAPI
}
public static var authenticatedAPIPromise: Promise<HomeAssistantAPI> {
return Promise { seal in
if let api = self.authenticatedAPI() {
seal.fulfill(api)
return
}
seal.reject(APIError.notConfigured)
}
}
public func VideoStreamer() -> MJPEGStreamer? {
guard let newManager = self.authenticatedSessionManager() else {
return nil
}
return MJPEGStreamer(manager: newManager)
}
public enum ConnectReason {
case cold
case warm
case periodic
var updateSensorTrigger: LocationUpdateTrigger {
switch self {
case .cold, .warm:
return .Launch
case .periodic:
return .Periodic
}
}
}
public func Connect(reason: ConnectReason) -> Promise<Void> {
return firstly {
self.UpdateRegistration()
}.recover { error -> Promise<MobileAppRegistrationResponse> 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 self.Register()
case .noApi,
.unregisteredIdentifier,
.unacceptableStatusCode,
.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 { _ in
return when(fulfilled: [
self.GetConfig().asVoid(),
Current.modelManager.fetch(),
self.UpdateSensors(trigger: reason.updateSensorTrigger).asVoid(),
self.updateComplications(passively: false).asVoid()
]).asVoid()
}.get { _ in
NotificationCenter.default.post(name: Self.didConnectNotification,
object: nil, userInfo: nil)
}
}
public func CreateEvent(eventType: String, eventData: [String: Any]) -> Promise<Void> {
if #available(iOS 12, *) {
let intent = FireEventIntent(eventName: eventType, payload: eventData)
INInteraction(intent: intent, response: nil).donate(completion: nil)
}
return Current.webhooks.send(
identifier: .unhandled,
request: .init(type: "fire_event", data: [
"event_type": eventType,
"event_data": eventData
])
)
}
private func getTemporaryDownloadDataPath(_ downloadingURL: URL) -> URL? {
return 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)
.appendingPathExtension(downloadingURL.pathExtension)
}
private func removeOldDownloadDirectory() {
let fileManager = FileManager.default
if let downloadDataDir = fileManager.containerURL(
forSecurityApplicationGroupIdentifier: Constants.AppGroupID
)?.appendingPathComponent("downloadedData", isDirectory: true) {
try? fileManager.removeItem(at: downloadDataDir)
}
}
public func DownloadDataAt(url: URL, needsAuth: Bool) -> Promise<URL> {
return Promise { seal in
var finalURL = url
let dataManager: Alamofire.SessionManager = needsAuth ? self.manager : Self.unauthenticatedManager
if needsAuth {
let activeURL = try connectionInfo().activeURL
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 = self.getTemporaryDownloadDataPath(finalURL) else {
Current.Log.error("Unable to get download path!")
seal.reject(APIError.cantBuildURL)
return
}
let destination: DownloadRequest.DownloadFileDestination = { _, _ in
return (downloadPath, [.removePreviousFile, .createIntermediateDirectories])
}
dataManager.download(finalURL, to: destination).responseData { downloadResponse in
switch downloadResponse.result {
case .success:
seal.fulfill(downloadResponse.destinationURL!)
case .failure(let error):
seal.reject(error)
}
}
}
}
public func GetConfig(_ useWebhook: Bool = true) -> Promise<ConfigResponse> {
let promise: Promise<ConfigResponse>
if useWebhook {
promise = Current.webhooks.sendEphemeral(request: .init(type: "get_config", data: [:]))
} else {
promise = request(path: "config", callingFunctionName: "\(#function)")
}
return promise.then { config -> Promise<ConfigResponse> in
HomeAssistantAPI.LoadedComponents = config.Components
guard self.MobileAppComponentLoaded else {
Current.Log.error("mobile_app component is not loaded!")
throw HomeAssistantAPI.APIError.mobileAppComponentNotLoaded
}
let connectionInfo = try self.connectionInfo()
connectionInfo.cloudhookURL = config.CloudhookURL
connectionInfo.setAddress(config.RemoteUIURL, .remoteUI)
self.prefs.setValue(config.LocationName, forKey: "location_name")
self.prefs.setValue(config.Latitude, forKey: "latitude")
self.prefs.setValue(config.Longitude, forKey: "longitude")
self.prefs.setValue(config.TemperatureUnit, forKey: "temperature_unit")
self.prefs.setValue(config.LengthUnit, forKey: "length_unit")
self.prefs.setValue(config.MassUnit, forKey: "mass_unit")
self.prefs.setValue(config.PressureUnit, forKey: "pressure_unit")
self.prefs.setValue(config.VolumeUnit, forKey: "volume_unit")
self.prefs.setValue(config.Timezone, forKey: "time_zone")
self.prefs.setValue(config.Version, forKey: "version")
self.prefs.setValue(config.ThemeColor, forKey: "themeColor")
Current.setUserProperty?(config.Version, "HA_Version")
return Promise.value(config)
}
}
public func GetEvents() -> Promise<[EventsResponse]> {
return self.request(path: "events", callingFunctionName: "\(#function)")
}
public func GetStates() -> Promise<[Entity]> {
return self.request(path: "states", callingFunctionName: "\(#function)")
}
public func GetScenes() -> Promise<[Scene]> {
return self.request(path: "states", callingFunctionName: "\(#function)")
}
public func GetServices() -> Promise<[ServicesResponse]> {
return self.request(path: "services", callingFunctionName: "\(#function)")
}
public func CallService(domain: String, service: String, serviceData: [String: Any],
shouldLog: Bool = true) -> Promise<Void> {
if #available(iOS 12, *) {
let intent = CallServiceIntent(domain: domain, service: service, payload: serviceData)
INInteraction(intent: intent, response: nil).donate(completion: nil)
}
return Current.webhooks.send(
identifier: .serviceCall,
request: .init(type: "call_service", data: [
"domain": domain,
"service": service,
"service_data": serviceData
])
)
}
public func RenderTemplate(templateStr: String, variables: [String: Any] = [:]) -> Promise<String> {
let hookPayload: [String: [String: Any]] = ["tpl": ["template": templateStr, "variables": variables]]
let req: Promise<Any> = Current.webhooks.sendEphemeral(
request: .init(type: "render_template", data: hookPayload)
)
return req.then { (resp: Any) -> Promise<String> in
guard let jsonDict = resp as? [String: String] else {
return Promise.value("Error")
}
guard let rendered = jsonDict["tpl"] else {
return Promise.value("Error")
}
return Promise.value(rendered)
}
}
public func GetCameraImage(cameraEntityID: String) -> Promise<UIImage> {
return Promise { seal in
let connectionInfo = try self.connectionInfo()
let queryUrl = connectionInfo.activeAPIURL.appendingPathComponent("camera_proxy/\(cameraEntityID)")
_ = manager.request(queryUrl)
.validate()
.responseData { response in
switch response.result {
case .success:
if let data = response.result.value, let image = UIImage(data: data) {
seal.fulfill(image)
}
case .failure(let error):
Current.Log.error("Error when attemping to GetCameraImage(): \(error)")
seal.reject(error)
}
}
}
}
public func Register() -> Promise<MobileAppRegistrationResponse> {
return self.request(path: "mobile_app/registrations", callingFunctionName: "\(#function)", method: .post,
parameters: buildMobileAppRegistration(), encoding: JSONEncoding.default)
.then { (resp: MobileAppRegistrationResponse) -> Promise<MobileAppRegistrationResponse> in
Current.Log.verbose("Registration response \(resp)")
let connectionInfo = try self.connectionInfo()
connectionInfo.setAddress(resp.RemoteUIURL, .remoteUI)
connectionInfo.cloudhookURL = resp.CloudhookURL
connectionInfo.webhookID = resp.WebhookID
connectionInfo.webhookSecret = resp.WebhookSecret
return Promise.value(resp)
}
}
public func UpdateRegistration() -> Promise<MobileAppRegistrationResponse> {
Current.webhooks.sendEphemeral(request: .init(
type: "update_registration",
data: buildMobileAppUpdateRegistration()
))
}
public func GetZones() -> Promise<[Zone]> {
Current.webhooks.sendEphemeral(request: .init(type: "get_zones", data: [:]))
}
public func GetMobileAppConfig() -> Promise<MobileAppConfig> {
return firstly { () -> Promise<MobileAppConfig> in
if Current.serverVersion() < .actionSyncing {
let old: Promise<MobileAppConfigPush> = requestImmutable(
path: "ios/push",
callingFunctionName: "\(#function)"
)
return old.map { MobileAppConfig(push: $0) }
} else {
return requestImmutable(path: "ios/config", callingFunctionName: "\(#function)")
}
}.recover { error -> Promise<MobileAppConfig> 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<StreamCameraResponse> {
Current.webhooks.sendEphemeral(request: .init(
type: "stream_camera",
data: ["camera_entity_id": entityId]
))
}
private func buildMobileAppRegistration() -> [String: Any] {
let ident = mobileAppRegistrationRequestModel()
var json = Mapper().toJSON(ident)
if Current.serverVersion() < .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 {
return with(MobileAppRegistrationRequest()) {
if let pushID = Current.settingsStore.pushID {
$0.AppData = [
"push_url": "https://mobile-apps.home-assistant.io/api/sendPushNotification",
"push_token": pushID
]
}
$0.AppIdentifier = Constants.BundleID
$0.AppName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String
$0.AppVersion = HomeAssistantAPI.clientVersionDescription
$0.DeviceID = Current.settingsStore.integrationDeviceID
$0.DeviceName = Current.settingsStore.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: CLLocation?, zone: RLMZone?) -> Promise<Void> {
firstly {
.value(WebhookUpdateLocation(
trigger: updateType,
location: location,
zone: zone
))
}.map { (update: WebhookUpdateLocation?) -> WebhookUpdateLocation in
if let update = update {
return update
} else {
throw HomeAssistantAPI.APIError.updateNotPossible
}
}.map { payload -> [String: Any] in
let realm = Current.realm()
// swiftlint:disable:next force_try
try! realm.write {
var jsonPayload = "{\"missing\": \"payload\"}"
if let p = payload.toJSONString(prettyPrint: false) {
jsonPayload = p
}
realm.add(LocationHistoryEntry(updateType: updateType, location: payload.cllocation,
zone: zone, payload: jsonPayload))
}
let payloadDict: [String: Any] = Mapper<WebhookUpdateLocation>().toJSON(payload)
Current.Log.info("Location update payload: \(payloadDict)")
return payloadDict
}.then { payload in
return when(resolved:
self.UpdateSensors(trigger: updateType, location: location).asVoid(),
Current.webhooks.send(
identifier: .location,
request: .init(
type: "update_location",
data: payload,
localMetadata: WebhookResponseLocation.localMetdata(
trigger: updateType,
zone: zone
)
)
)
)
}.asVoid()
}
public func GetAndSendLocation(
trigger: LocationUpdateTrigger?,
zone: RLMZone? = nil,
maximumBackgroundTime: TimeInterval? = nil
) -> Promise<Void> {
var updateTrigger: LocationUpdateTrigger = .Manual
if let trigger = trigger {
updateTrigger = trigger
}
Current.Log.verbose("getAndSendLocation called via \(String(describing: updateTrigger))")
return firstly { () -> Promise<CLLocation> in
return CLLocationManager.oneShotLocation(
timeout: updateTrigger.oneShotTimeout(maximum: maximumBackgroundTime)
)
}.then { location in
self.SubmitLocation(updateType: updateTrigger, location: location, zone: zone)
}.asVoid()
}
private func sendLocalNotification(withZone: RLMZone?, updateType: LocationUpdateTrigger,
payloadDict: [String: Any]) {
let zoneName = withZone?.Name ?? "Unknown zone"
let notificationOptions = updateType.notificationOptionsFor(zoneName: zoneName)
Current.clientEventStore.addEvent(ClientEvent(text: notificationOptions.body, type: .locationUpdate,
payload: payloadDict))
if notificationOptions.shouldNotify {
let content = UNMutableNotificationContent()
content.title = notificationOptions.title
content.body = notificationOptions.body
content.sound = UNNotificationSound.default
let notificationRequest =
UNNotificationRequest.init(identifier: notificationOptions.identifier ?? "",
content: content, trigger: nil)
UNUserNotificationCenter.current().add(notificationRequest)
}
}
private class var sharedEventDeviceInfo: [String: String] { [
"sourceDevicePermanentID": Constants.PermanentID,
"sourceDeviceName": Current.settingsStore.overrideDeviceName ?? Current.device.deviceName(),
"sourceDeviceID": Current.settingsStore.deviceID
] }
public enum ActionSource: String, CaseIterable, CustomStringConvertible {
case Watch = "watch"
case Widget = "widget"
case AppShortcut = "appShortcut" // UIApplicationShortcutItem
case Preview = "preview"
case SiriShortcut = "siriShortcut"
case URLHandler = "urlHandler"
public var description: String {
rawValue
}
}
public class func notificationActionEvent(
identifier: String,
category: String?,
actionData: Any?,
textInput: String?
) -> (eventType: String, eventData: [String: Any]) {
var eventData: [String: Any] = sharedEventDeviceInfo
eventData["actionName"] = identifier
if let category = category {
eventData["categoryName"] = category
}
if let actionData = actionData {
eventData["action_data"] = actionData
}
if let textInput = textInput {
eventData["response_info"] = textInput
eventData["textInput"] = textInput
}
return (eventType: "ios.notification_action_fired", eventData: eventData)
}
public class func actionEvent(
actionID: String,
actionName: String,
source: ActionSource
) -> (eventType: String, eventData: [String: String]) {
var eventData = sharedEventDeviceInfo
eventData["actionName"] = actionName
eventData["actionID"] = actionID
eventData["triggerSource"] = source.description
return (eventType: "ios.action_fired", eventData: eventData)
}
public class func actionScene(
actionID: String,
source: ActionSource
) -> (serviceDomain: String, serviceName: String, serviceData: [String: String]) {
return (serviceDomain: "scene", serviceName: "turn_on", serviceData: [ "entity_id": actionID ])
}
public class func tagEvent(
tagPath: String
) -> (eventType: String, eventData: [String: String]) {
var eventData = [String: String]()
eventData["tag_id"] = tagPath
if Current.serverVersion() < .tagWebhookAvailable {
eventData["device_id"] = Current.settingsStore.integrationDeviceID
}
return (eventType: "tag_scanned", eventData: eventData)
}
@available(watchOS, unavailable)
public class func zoneStateEvent(
region: CLRegion,
state: CLRegionState,
zone: RLMZone
) -> (eventType: String, eventData: [String: Any]) {
var eventData: [String: Any] = sharedEventDeviceInfo
eventData["zone"] = zone.ID
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 class 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 func handlePushAction(
identifier: String,
category: String?,
userInfo: [AnyHashable: Any],
userInput: String?
) -> Promise<Void> {
return Promise { seal in
guard let api = HomeAssistantAPI.authenticatedAPI() else {
throw APIError.notConfigured
}
let action = Self.notificationActionEvent(
identifier: identifier,
category: category,
actionData: userInfo["homeassistant"],
textInput: userInput
)
Current.Log.verbose("Sending action: \(action.eventType) payload: \(action.eventData)")
api.CreateEvent(eventType: action.eventType, eventData: action.eventData).done { _ -> Void in
seal.fulfill(())
}.catch {error in
seal.reject(error)
}
}
}
public func HandleAction(actionID: String, source: ActionSource) -> Promise<Void> {
return Promise { seal in
guard let api = HomeAssistantAPI.authenticatedAPI() else {
throw APIError.notConfigured
}
guard let action = Current.realm().object(ofType: Action.self, forPrimaryKey: actionID) else {
Current.Log.error("couldn't find action with id \(actionID)")
throw HomeAssistantAPI.APIError.cantBuildURL
}
if #available(iOS 12, *) {
let intent = PerformActionIntent(action: action)
INInteraction(intent: intent, response: nil).donate(completion: nil)
}
switch action.triggerType {
case .event:
let actionInfo = Self.actionEvent(actionID: action.ID, actionName: action.Name, source: source)
Current.Log.verbose("Sending action: \(actionInfo.eventType) payload: \(actionInfo.eventData)")
api.CreateEvent(
eventType: actionInfo.eventType,
eventData: actionInfo.eventData
).pipe(to: { seal.resolve($0) })
case .scene:
let serviceInfo = Self.actionScene(actionID: action.ID, source: source)
Current.Log.verbose("activating scene: \(action.ID)")
api.CallService(
domain: serviceInfo.serviceDomain,
service: serviceInfo.serviceName,
serviceData: serviceInfo.serviceData
).pipe(to: { seal.resolve($0) })
}
}
}
public func RegisterSensors() -> Promise<Void> {
return firstly {
Current.sensors.sensors(reason: .registration)
}.get { sensors in
Current.Log.verbose("Registering sensors \(sensors.map { $0.UniqueID })")
}.thenMap { sensor in
Current.webhooks.send(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<Void> {
return firstly {
Current.sensors.sensors(
reason: .trigger(trigger.rawValue),
location: location
)
}.map { sensors in
Current.Log.info("updating sensors \(sensors.map { $0.UniqueID ?? "unknown" })")
let mapper = Mapper<WebhookSensor>(context: WebhookSensorContext(update: true),
shouldIncludeNilValues: false)
return mapper.toJSONArray(sensors)
}.then { (payload) -> Promise<Void> in
Current.webhooks.send(
identifier: .updateSensors,
request: .init(type: "update_sensor_states", data: payload)
)
}
}
}
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 .mustUpgradeHomeAssistant(let current):
return L10n.HaApi.ApiError.mustUpgradeHomeAssistant(current.description,
HomeAssistantAPI.minimumRequiredVersion.description)
case .unknown:
return L10n.HaApi.ApiError.unknown
}
}
}
extension HomeAssistantAPI: SensorObserver {
public func sensorContainerDidSignalForUpdate(_ container: SensorContainer) {
Current.backgroundTask(withName: "signaled-update-sensors") { _ in
UpdateSensors(trigger: .Signaled)
}.cauterize()
}
public func sensorContainer(_ container: SensorContainer, didUpdate update: SensorObserverUpdate) {
// we don't do anything for this
}
}