mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-25 07:32:12 -05:00
<!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. -->
202 lines
6.6 KiB
Swift
202 lines
6.6 KiB
Swift
import Communicator
|
|
import Foundation
|
|
import NetworkExtension
|
|
import PromiseKit
|
|
import Shared
|
|
|
|
final class WatchMagicViewRowViewModel: ObservableObject {
|
|
enum RowState {
|
|
case idle
|
|
case loading
|
|
case success
|
|
case failure
|
|
}
|
|
|
|
enum MagicItemResponse {
|
|
case success
|
|
case failed
|
|
case tookLonger
|
|
|
|
var rowState: RowState {
|
|
switch self {
|
|
case .success:
|
|
return .success
|
|
case .failed:
|
|
return .failure
|
|
case .tookLonger:
|
|
return .idle
|
|
}
|
|
}
|
|
}
|
|
|
|
@Published private(set) var state: RowState = .idle
|
|
@Published var showConfirmationDialog = false
|
|
/// Set when an execution fails, so the failure isn't silent. Presented as an alert by the row.
|
|
@Published var errorMessage: String?
|
|
|
|
@Published private(set) var item: MagicItem
|
|
@Published private(set) var itemInfo: MagicItem.Info
|
|
|
|
private var timeoutWorkItem: DispatchWorkItem?
|
|
|
|
init(item: MagicItem, itemInfo: MagicItem.Info) {
|
|
self.item = item
|
|
self.itemInfo = itemInfo
|
|
}
|
|
|
|
func executeItem() {
|
|
if itemInfo.customization?.requiresConfirmation ?? true {
|
|
showConfirmationDialog = true
|
|
} else {
|
|
executeItemAction()
|
|
}
|
|
}
|
|
|
|
func confirmationAction() {
|
|
executeItemAction()
|
|
}
|
|
|
|
private func executeItemAction() {
|
|
errorMessage = nil
|
|
state = .loading
|
|
executeMagicItem { [weak self] response in
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.state = response.rowState
|
|
}
|
|
self?.resetState()
|
|
}
|
|
}
|
|
|
|
private func resetState() {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
|
|
self?.state = .idle
|
|
}
|
|
}
|
|
|
|
private func fetchNetworkInfo(completion: (() -> Void)? = nil) {
|
|
Current.networkInformation { hotspotNetwork in
|
|
WatchUserDefaults.shared.set(hotspotNetwork?.ssid, key: .watchSSID)
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
private func executeMagicItemUsingiPhone(magicItem: MagicItem, completion: @escaping (Bool) -> Void) {
|
|
Current.Log.verbose("Signaling magic item pressed via phone")
|
|
let itemMessage = InteractiveImmediateMessage(
|
|
identifier: InteractiveImmediateMessages.magicItemPressed.rawValue,
|
|
content: [
|
|
"itemId": magicItem.id,
|
|
"serverId": magicItem.serverId,
|
|
"itemType": magicItem.type.rawValue,
|
|
],
|
|
reply: { message in
|
|
Current.Log.verbose("Received reply dictionary \(message)")
|
|
if message.content["fired"] as? Bool == true {
|
|
completion(true)
|
|
} else {
|
|
completion(false)
|
|
}
|
|
}
|
|
)
|
|
|
|
Current.Log
|
|
.verbose(
|
|
"Sending \(InteractiveImmediateMessages.magicItemPressed.rawValue) message \(itemMessage)"
|
|
)
|
|
Communicator.shared.send(itemMessage, errorHandler: { [weak self] error in
|
|
Current.Log.error("Received error when sending immediate message \(error)")
|
|
self?.presentError(error)
|
|
completion(false)
|
|
})
|
|
}
|
|
|
|
private func executeMagicItemUsingAPI(magicItem: MagicItem, completion: @escaping (Bool) -> Void) {
|
|
guard let server = Current.servers.all.first(where: { $0.identifier.rawValue == magicItem.serverId }) else {
|
|
presentError(nil)
|
|
completion(false)
|
|
return
|
|
}
|
|
Current.Log.error("Executing watch magic item directly via API")
|
|
|
|
magicItem.execute(on: server, source: .Watch) { [weak self] success, error in
|
|
if !success {
|
|
self?.presentError(error)
|
|
}
|
|
completion(success)
|
|
}
|
|
}
|
|
|
|
/// Surface a failure to the user instead of letting it fail silently. Uses the underlying
|
|
/// error's description when available (e.g. a connection / TLS / "no active URL" error).
|
|
private func presentError(_ error: Error?) {
|
|
let message = error?.localizedDescription ?? L10n.Watch.Home.Run.Error.message
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.errorMessage = message
|
|
}
|
|
}
|
|
|
|
private func executeMagicItem(completion: @escaping (MagicItemResponse) -> Void) {
|
|
let timeTriggered = Date()
|
|
let magicItem = item
|
|
Current.Log.verbose("Selected magic item id: \(magicItem.id)")
|
|
fetchNetworkInfo { [weak self] in
|
|
guard let self else { return }
|
|
if shouldUsePhone {
|
|
executeMagicItemUsingiPhone(magicItem: magicItem) { success in
|
|
// Avoid haptics in background
|
|
guard self.isLessThan30Seconds(from: timeTriggered) else {
|
|
completion(.tookLonger)
|
|
return
|
|
}
|
|
if success {
|
|
self.cancelTimeout()
|
|
completion(success ? .success : .failed)
|
|
} else {
|
|
completion(.failed)
|
|
}
|
|
}
|
|
} else {
|
|
executeMagicItemUsingAPI(magicItem: magicItem) { success in
|
|
self.cancelTimeout()
|
|
completion(success ? .success : .failed)
|
|
}
|
|
}
|
|
startTimeoutTimerWhichResetsState(completion: completion)
|
|
}
|
|
}
|
|
|
|
/// Resolve where to run the action from the user's "Perform action using" preference: always the
|
|
/// iPhone, always the Watch (direct), or automatically (iPhone when reachable, else direct).
|
|
private var shouldUsePhone: Bool {
|
|
switch WatchUserDefaults.shared.performActionTarget {
|
|
case .iPhone:
|
|
return true
|
|
case .appleWatch:
|
|
return false
|
|
case .auto:
|
|
return Communicator.shared.currentReachability == .immediatelyReachable
|
|
}
|
|
}
|
|
|
|
// Given date returns if is less than 30 seconds from now
|
|
private func isLessThan30Seconds(from date: Date) -> Bool {
|
|
Date().timeIntervalSince(date) < 30
|
|
}
|
|
|
|
private func startTimeoutTimerWhichResetsState(completion: @escaping (MagicItemResponse) -> Void) {
|
|
timeoutWorkItem?.cancel()
|
|
|
|
timeoutWorkItem = DispatchWorkItem {
|
|
completion(.tookLonger)
|
|
}
|
|
|
|
if let workItem = timeoutWorkItem {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 4, execute: workItem)
|
|
}
|
|
}
|
|
|
|
private func cancelTimeout() {
|
|
timeoutWorkItem?.cancel()
|
|
}
|
|
}
|