Files
iOS/Sources/Shared/API/HAAPI.swift
Zac West 4d9a530637 Reorganize files in repo, pull out build settings from pbxproj (#1140)
This is somewhat in prep of being able to make the project file generated, but also just organizes things into more concrete directory structures.

This pulls out _all_ of the build settings from the root level, and most from the target level, into xcconfigs.

The new directory structure looks like:

- Sources
  - App
    - (everything from HomeAssistant/)
  - WatchApp
  - Shared
  - MacBridge
  - Extensions
    - Intents
    - NotificationContent
    - NotificationService
    - Share
    - Today
    - Watch
    - Widgets
- Tests
  - App
  - UI
  - Shared

Somewhat intentionally, the file structure under these is not yet standardized/organized.

The project targets are now:

- App
- WatchApp
- Shared-iOS
- Shared-watchOS
- MacBridge
- Tests-App
- Tests-UI
- Tests-Shared
- Extension-Intents
- Extension-NotificationContent
- Extension-NotificationService
- Extension-Share
- Extension-Today
- Extension-Widget
- WatchExtension-Watch

This does not yet clean up resources vs. sources, nor does it handle some of the "it's in Sources/App but it's part of Shared" crossover directory issues.
2020-10-03 00:15:04 -07:00

802 lines
30 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().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
)
)
),
self.updateComplications().asVoid()
)
}.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 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
}
}