From a043a51beaf89cf615a3928830d4d3dd4e887583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o=20Gon=C3=A7alves?= <5808343+bgoncal@users.noreply.github.com> Date: Thu, 12 Dec 2024 18:05:32 +0100 Subject: [PATCH 1/2] Warning when internal URL requires more permissions before it can be used (#3267) ## Summary ## Screenshots https://github.com/user-attachments/assets/3dcdf423-c302-4e30-84e3-433bba03bfa9 ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant# ## Any other notes --- HomeAssistant.xcodeproj/project.pbxproj | 4 + .../App/Onboarding/API/OnboardingAuth.swift | 9 +- .../Resources/en.lproj/Localizable.strings | 8 + .../ConnectionURLViewController.swift | 2 +- Sources/App/WebView/NoActiveURLView.swift | 174 ++++++++++++++++++ Sources/App/WebView/WebViewController.swift | 24 +-- .../DesignSystem/Styles/HAButtonStyles.swift | 21 +++ .../Shared/Resources/Swiftgen/Strings.swift | 27 +++ 8 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 Sources/App/WebView/NoActiveURLView.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index e78458977..c1e6a9dbe 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -543,6 +543,7 @@ 420B100C2B1D204400D383D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 420B100B2B1D204400D383D8 /* Assets.xcassets */; }; 420C1BB22CF7DA9100AF22E7 /* ClientEventsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420C1BB12CF7DA9100AF22E7 /* ClientEventsLogView.swift */; }; 420C1BB52CF7DC1400AF22E7 /* ClientEventsLogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420C1BB42CF7DC1400AF22E7 /* ClientEventsLogViewModel.swift */; }; + 420C57C72D0A6DE700D2D9AC /* NoActiveURLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420C57C62D0A6DE700D2D9AC /* NoActiveURLView.swift */; }; 420D5AE32C5A860900624A08 /* LocationPermissionSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420D5AE22C5A860900624A08 /* LocationPermissionSensor.swift */; }; 420D5AE42C5A860900624A08 /* LocationPermissionSensor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420D5AE22C5A860900624A08 /* LocationPermissionSensor.swift */; }; 420E2AE32C4746BB004921D8 /* WidgetBasicViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420E2AE22C4746BB004921D8 /* WidgetBasicViewModel.swift */; }; @@ -1832,6 +1833,7 @@ 420B100B2B1D204400D383D8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 420C1BB12CF7DA9100AF22E7 /* ClientEventsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEventsLogView.swift; sourceTree = ""; }; 420C1BB42CF7DC1400AF22E7 /* ClientEventsLogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientEventsLogViewModel.swift; sourceTree = ""; }; + 420C57C62D0A6DE700D2D9AC /* NoActiveURLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoActiveURLView.swift; sourceTree = ""; }; 420D5AE22C5A860900624A08 /* LocationPermissionSensor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissionSensor.swift; sourceTree = ""; }; 420E2AE22C4746BB004921D8 /* WidgetBasicViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBasicViewModel.swift; sourceTree = ""; }; 420E2AE42C4746CD004921D8 /* WidgetBasicSizeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetBasicSizeStyle.swift; sourceTree = ""; }; @@ -3105,6 +3107,7 @@ 113FB1122515A065000AC680 /* ScaleFactorMutator.swift */, 11DE822D24FAC51000E636B8 /* IncomingURLHandler.swift */, B64BB3A71E9C6551001E8B46 /* WebViewController.swift */, + 420C57C62D0A6DE700D2D9AC /* NoActiveURLView.swift */, 42A47A842C45218D00C9B43D /* WebViewExternalMessageHandler.swift */, 42A47A8B2C4547B800C9B43D /* WebViewExternalMessageHandler+Build.swift */, 42B95B512BE007E30070F2D4 /* SafeScriptMessageHandler.swift */, @@ -6924,6 +6927,7 @@ 42E6C08A2CE4F4FA007CA622 /* DownloadManagerView.swift in Sources */, 42E95C572CA45EFA0010ECE3 /* OnboardingErrorView.swift in Sources */, B641BC1F1E2097EF002CCBC1 /* AboutViewController.swift in Sources */, + 420C57C72D0A6DE700D2D9AC /* NoActiveURLView.swift in Sources */, 42E95C592CA46AD50010ECE3 /* ActivityView.swift in Sources */, B675ECC3221BB0E600C65D31 /* SearchPushRow.swift in Sources */, 11C05F2D254919210031D038 /* AccountInitialsImage.swift in Sources */, diff --git a/Sources/App/Onboarding/API/OnboardingAuth.swift b/Sources/App/Onboarding/API/OnboardingAuth.swift index 0f769e386..7916c095e 100644 --- a/Sources/App/Onboarding/API/OnboardingAuth.swift +++ b/Sources/App/Onboarding/API/OnboardingAuth.swift @@ -58,7 +58,11 @@ class OnboardingAuth { // not super necessary but prevents making a duplicate connection during this session Current.cachedApis[api.server.identifier] = api }.then { server in - steps(.complete).map { server } + server.update { info in + // Disable fallback to internal URL after onboarding + info.connection.alwaysFallbackToInternalURL = false + } + return steps(.complete).map { server } }.recover(policy: .allErrors) { [self] error -> Promise in when(resolved: undoConfigure(api: api)).then { _ in Promise(error: error) } } @@ -156,6 +160,9 @@ class OnboardingAuth { var connectionInfo = ConnectionInfo(discovered: instance, authDetails: authDetails) + // During onboarding we need at least one URL available, this is disabled at the end of onboarding + connectionInfo.alwaysFallbackToInternalURL = true + return tokenExchange.tokenInfo( code: code, connectionInfo: &connectionInfo diff --git a/Sources/App/Resources/en.lproj/Localizable.strings b/Sources/App/Resources/en.lproj/Localizable.strings index 7b9692654..1488a4b1b 100644 --- a/Sources/App/Resources/en.lproj/Localizable.strings +++ b/Sources/App/Resources/en.lproj/Localizable.strings @@ -1087,3 +1087,11 @@ Home Assistant is free and open source home automation software with a focus on "widgets.sensors.not_configured" = "No Sensors Configured"; "widgets.sensors.title" = "Sensors"; "yes_label" = "Yes"; +"connection.permission.internal_url.title" = "Permission access"; +"connection.permission.internal_url.body1" = "To access Home Assistant locally in a secure way, you need to grant the location permission ('Always')."; +"connection.permission.internal_url.body2" = "This permission allows Home Assistant to detect the wireless network that you're connected to and establish a local connection."; +"connection.permission.internal_url.body3" = "You are always in control if your location is shared with Home Assistant. You can change these settings in the companion app setting screen."; +"connection.permission.internal_url.button_configure" = "Configure local access"; +"connection.permission.internal_url.button_ignore" = "I know what I am doing. Allow local connections without permission access."; +"connection.permission.internal_url.footer" = "If you still want to use the local URL and don't want to provide location permission, you can tap the button below, but please, be aware of the security risks."; +"connection.permission.internal_url.ignore.alert.title" = "Are you sure?"; diff --git a/Sources/App/Settings/Connection/ConnectionURLViewController.swift b/Sources/App/Settings/Connection/ConnectionURLViewController.swift index 048aa24be..81e8d6f70 100644 --- a/Sources/App/Settings/Connection/ConnectionURLViewController.swift +++ b/Sources/App/Settings/Connection/ConnectionURLViewController.swift @@ -310,7 +310,7 @@ final class ConnectionURLViewController: HAFormViewController, TypedRowControlle let alert = UIAlertController( title: L10n.Settings.ConnectionSection.AlwaysFallbackInternal.Confirmation.title, message: L10n.Settings.ConnectionSection.AlwaysFallbackInternal.Confirmation.message, - preferredStyle: .actionSheet + preferredStyle: .alert ) alert.addAction(UIAlertAction(title: L10n.cancelLabel, style: .cancel, handler: { _ in self?.server.info.connection.alwaysFallbackToInternalURL = false diff --git a/Sources/App/WebView/NoActiveURLView.swift b/Sources/App/WebView/NoActiveURLView.swift new file mode 100644 index 000000000..babbdcf10 --- /dev/null +++ b/Sources/App/WebView/NoActiveURLView.swift @@ -0,0 +1,174 @@ +import SFSafeSymbols +import Shared +import SwiftUI + +struct NoActiveURLView: View { + @Environment(\.dismiss) private var dismiss + let server: Server + + @State private var showIgnoreConfirmation = false + + var body: some View { + ScrollView { + VStack { + VStack { + header + Image(imageAsset: Asset.SharedAssets.logo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 140) + + textBlock + configureButton + } + .padding() + footer + } + } + .ignoresSafeArea(edges: .bottom) + .onDisappear { + Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) + .done { webViewController in + webViewController.overlayAppController = nil + } + } + .alert(L10n.Connection.Permission.InternalUrl.Ignore.Alert.title, isPresented: $showIgnoreConfirmation) { + Button(L10n.yesLabel, role: .destructive) { + ignore() + } + } + } + + private func ignore() { + server.update { info in + info.connection.alwaysFallbackToInternalURL = true + + Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) + .done { webViewController in + dismiss() + webViewController.reload() + } + } + } + + private var configureButton: some View { + Button(L10n.Connection.Permission.InternalUrl.buttonConfigure) { + Current.Log.info("Tapped configure local access button in NoActiveURLView") + configure() + } + .buttonStyle(.primaryButton) + .padding(.vertical) + } + + private func configure() { + Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) + .done { webViewController in + let controller = ConnectionURLViewController( + server: server, + urlType: .internal, + row: .init(tag: "") + ) + let navController = UINavigationController(rootViewController: controller) + controller.onDismissCallback = { _ in + navController.dismiss(animated: true) { + webViewController.reload() + } + } + webViewController.presentOverlayController(controller: navController, animated: true) + } + } + + private var header: some View { + HStack { + Group { + Button { + Current.Log.info("Tapped settings button in NoActiveURLView") + showSettings() + } label: { + Image(systemSymbol: .gear) + } + Spacer() + Button { + Current.Log.info("Dismissed NoActiveURLView") + dismiss() + } label: { + Image(systemSymbol: .xmark) + } + } + .font(.title2) + .foregroundStyle(Color(uiColor: .secondaryLabel)) + } + } + + private func showSettings() { + Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise) + .done { webViewController in + webViewController.showSettingsViewController() + } + } + + @ViewBuilder + private var textBlock: some View { + Text(L10n.Connection.Permission.InternalUrl.title) + .font(.title.bold()) + .padding(.vertical) + VStack(spacing: Spaces.two) { + Group { + makeRow(icon: .map, text: L10n.Connection.Permission.InternalUrl.body1) + makeRow(icon: .wifi, text: L10n.Connection.Permission.InternalUrl.body2) + makeRow(icon: .lock, text: L10n.Connection.Permission.InternalUrl.body3) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func makeRow(icon: SFSymbol, text: String) -> some View { + HStack(spacing: Spaces.two) { + VStack { + Image(systemSymbol: icon) + .font(.title) + .foregroundStyle(Color(uiColor: Asset.Colors.haPrimary.color)) + } + .frame(width: 30, height: 30) + Text(text) + .font(.body) + } + } + + private var footer: some View { + VStack { + Text( + L10n.Connection.Permission.InternalUrl.footer + ) + .font(.footnote) + .multilineTextAlignment(.center) + Button(L10n.Connection.Permission.InternalUrl.buttonIgnore) { + showIgnoreConfirmation = true + } + .buttonStyle(.criticalButton) + .padding(.vertical) + } + .padding() + .padding(.vertical) + .background(Color(uiColor: .secondarySystemBackground)) + } +} + +#Preview { + VStack {} + .sheet(isPresented: .constant(true)) { + NoActiveURLView(server: ServerFixture.standard) + } +} + +final class NoActiveURLViewController: UIHostingController { + init(server: Server) { + super.init(rootView: NoActiveURLView(server: server)) + } + + @available(*, unavailable) + @MainActor @preconcurrency dynamic required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/App/WebView/WebViewController.swift b/Sources/App/WebView/WebViewController.swift index 07fe700e5..4d5823347 100644 --- a/Sources/App/WebView/WebViewController.swift +++ b/Sources/App/WebView/WebViewController.swift @@ -601,26 +601,9 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg private func showNoActiveURLError() { Current.Log.info("Showing noActiveURLError") - var config = swiftMessagesConfig() - config.duration = .seconds(seconds: 15) - let view = MessageView.viewFromNib(layout: .messageView) - view.configureContent( - title: L10n.Network.Error.NoActiveUrl.title, - body: L10n.Network.Error.NoActiveUrl.body, - iconImage: nil, - iconText: nil, - buttonImage: MaterialDesignIcons.cogIcon.image( - ofSize: CGSize(width: 30, height: 30), - color: Asset.Colors.haPrimary.color - ), - buttonTitle: nil, - buttonTapHandler: { [weak self] _ in - self?.showSettingsViewController() - SwiftMessages.hide() - } - ) - - SwiftMessages.show(config: config, view: view) + webView.scrollView.refreshControl?.endRefreshing() + guard !(overlayAppController is NoActiveURLViewController) else { return } + presentController(NoActiveURLViewController(server: server), animated: true) } @objc private func connectionInfoDidChange() { @@ -1103,6 +1086,7 @@ extension WebViewController: WebViewControllerProtocol { if let overlayAppController { overlayAppController.dismiss(animated: false) } + overlayAppController = controller present(controller, animated: animated) } } diff --git a/Sources/Shared/DesignSystem/Styles/HAButtonStyles.swift b/Sources/Shared/DesignSystem/Styles/HAButtonStyles.swift index 91c4a7597..563c8d469 100644 --- a/Sources/Shared/DesignSystem/Styles/HAButtonStyles.swift +++ b/Sources/Shared/DesignSystem/Styles/HAButtonStyles.swift @@ -24,6 +24,21 @@ public struct HASecondaryButtonStyle: ButtonStyle { } } +public struct HACriticalButtonStyle: ButtonStyle { + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .multilineTextAlignment(.center) + .font(.callout.bold()) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .frame(height: 55) + .padding() + .background(.red.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.red, lineWidth: 1)) + } +} + public struct HALinkButtonStyle: ButtonStyle { public func makeBody(configuration: Configuration) -> some View { configuration.label @@ -50,3 +65,9 @@ public extension ButtonStyle where Self == HALinkButtonStyle { HALinkButtonStyle() } } + +public extension ButtonStyle where Self == HACriticalButtonStyle { + static var criticalButton: HACriticalButtonStyle { + HACriticalButtonStyle() + } +} diff --git a/Sources/Shared/Resources/Swiftgen/Strings.swift b/Sources/Shared/Resources/Swiftgen/Strings.swift index 4115e0c80..dac564f8b 100644 --- a/Sources/Shared/Resources/Swiftgen/Strings.swift +++ b/Sources/Shared/Resources/Swiftgen/Strings.swift @@ -754,6 +754,33 @@ public enum L10n { } } + public enum Connection { + public enum Permission { + public enum InternalUrl { + /// To access Home Assistant locally in a secure way, you need to grant the location permission ('Always'). + public static var body1: String { return L10n.tr("Localizable", "connection.permission.internal_url.body1") } + /// This permission allows Home Assistant to detect the wireless network that you're connected to and establish a local connection. + public static var body2: String { return L10n.tr("Localizable", "connection.permission.internal_url.body2") } + /// You are always in control if your location is shared with Home Assistant. You can change these settings in the companion app setting screen. + public static var body3: String { return L10n.tr("Localizable", "connection.permission.internal_url.body3") } + /// Configure local access + public static var buttonConfigure: String { return L10n.tr("Localizable", "connection.permission.internal_url.button_configure") } + /// I know what I am doing. Allow local connections without permission access. + public static var buttonIgnore: String { return L10n.tr("Localizable", "connection.permission.internal_url.button_ignore") } + /// If you still want to use the local URL and don't want to provide location permission, you can tap the button below, but please, be aware of the security risks. + public static var footer: String { return L10n.tr("Localizable", "connection.permission.internal_url.footer") } + /// Permission access + public static var title: String { return L10n.tr("Localizable", "connection.permission.internal_url.title") } + public enum Ignore { + public enum Alert { + /// Are you sure? + public static var title: String { return L10n.tr("Localizable", "connection.permission.internal_url.ignore.alert.title") } + } + } + } + } + } + public enum Database { public enum Problem { /// Delete Database & Quit App From 600937ad46eae130037491c5f1acc5e20fa4b4a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Pantale=C3=A3o=20Gon=C3=A7alves?= <5808343+bgoncal@users.noreply.github.com> Date: Thu, 12 Dec 2024 18:06:14 +0100 Subject: [PATCH 2/2] Cache config/entity_registry/list_for_display (#3268) ## Summary So we can use the "decimal places" value to display sensors widget correctly CC: @Penait1 ## Screenshots ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant# ## Any other notes --- HomeAssistant.xcodeproj/project.pbxproj | 8 ++++ .../Extensions/GRDB+Initialization.swift | 30 +++++++++++++ .../Shared/EntityRegistryListForDisplay.swift | 33 +++++++++++++++ .../PeriodicAppEntitiesModelUpdater.swift | 42 +++++++++++++++++++ Sources/Shared/HATypedRequest+App.swift | 6 +++ 5 files changed, 119 insertions(+) create mode 100644 Sources/Shared/EntityRegistryListForDisplay.swift diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index c1e6a9dbe..13837ffdd 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -577,6 +577,9 @@ 422E25EE2C80019D00256D87 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 420B100B2B1D204400D383D8 /* Assets.xcassets */; }; 422E626C2CDCF00A00987BD0 /* AreaProvider.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422E626B2CDCF00A00987BD0 /* AreaProvider.test.swift */; }; 422F951F2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 422F951E2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift */; }; + 42333ADB2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */; }; + 42333ADC2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */; }; + 42333ADD2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */; }; 4235075D2CDB756800A19902 /* HAServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4235075C2CDB756800A19902 /* HAServices.swift */; }; 4235075E2CDB756800A19902 /* HAServices.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4235075C2CDB756800A19902 /* HAServices.swift */; }; 4239D1832C4FFCCE003497FC /* WatchUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4239D1802C4FFB75003497FC /* WatchUserDefaults.swift */; }; @@ -1857,6 +1860,7 @@ 422E25EC2C7FF28900256D87 /* ControlScriptsValueProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlScriptsValueProvider.swift; sourceTree = ""; }; 422E626B2CDCF00A00987BD0 /* AreaProvider.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AreaProvider.test.swift; sourceTree = ""; }; 422F951E2CFDF7C5003B7514 /* HAApplicationShortcutItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAApplicationShortcutItem.swift; sourceTree = ""; }; + 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityRegistryListForDisplay.swift; sourceTree = ""; }; 4235075C2CDB756800A19902 /* HAServices.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HAServices.swift; sourceTree = ""; }; 4239D1802C4FFB75003497FC /* WatchUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchUserDefaults.swift; sourceTree = ""; }; 423F44EF2C17238200766A99 /* ChatBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleView.swift; sourceTree = ""; }; @@ -4932,6 +4936,7 @@ 4278CB822D01F09400CFAAC9 /* HAGesture.swift */, 424D2D0F2C89DACE00C610F1 /* HAAppEntity.swift */, 42BB4C362CD26490003E47FD /* HATypedRequest+App.swift */, + 42333ADA2D0B1771001E8408 /* EntityRegistryListForDisplay.swift */, 42F1DA6F2B4EE2E8002729BC /* HAAreaResponse.swift */, 426D9C722C9C582F00F278AF /* ControlEntityProvider.swift */, 42A47D4A2C9AEF10003C597D /* DataWidgetsUpdater.swift */, @@ -6981,6 +6986,7 @@ 1185DFAE271FF53800ED7D9A /* OnboardingAuthStepConfig.swift in Sources */, 425573CE2B5574F100145217 /* CarPlayAreasViewModel.swift in Sources */, 42BA1BC92C8864C200A2FC36 /* OpenPageAppIntent.swift in Sources */, + 42333ADD2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */, B661FB7A226C197900E541DD /* OnboardingManualURLViewController.swift in Sources */, 119A827C252A3C4700D7000D /* NFCNDEFPayload+Additions.swift in Sources */, 42AC94A52CF872520050A62C /* TileCardStyleModifier.swift in Sources */, @@ -7207,6 +7213,7 @@ 113D29DF24946EDA0014067C /* CLLocationManager+OneShotLocation.swift in Sources */, 11CFD78227364F450082D557 /* Identifier.swift in Sources */, 11AF4D17249C8083006C74C0 /* With.swift in Sources */, + 42333ADB2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */, 11B38EF7275C54A300205C7B /* UpdateSensorsIntentHandler.swift in Sources */, 1141182B24AFA10900E6525C /* WebhookResponseHandler.swift in Sources */, 426266462C11B02C0081A818 /* InteractiveImmediateMessages.swift in Sources */, @@ -7453,6 +7460,7 @@ 4251AAC12C6CE9C4004CCC9D /* WatchConfig.swift in Sources */, D03D893620E0AEFA00D4F28D /* Environment.swift in Sources */, D0EEF2CE214D8AE200D1D360 /* RealmZone.swift in Sources */, + 42333ADC2D0B1771001E8408 /* EntityRegistryListForDisplay.swift in Sources */, 11C65CC0249838EB00D07FC7 /* StreamCameraResponse.swift in Sources */, B6A258452232485300ADD202 /* Alamofire+EncryptedResponses.swift in Sources */, 1182620424F9C453000795C6 /* HACoreMediaObjectSystem.swift in Sources */, diff --git a/Sources/Shared/Common/Extensions/GRDB+Initialization.swift b/Sources/Shared/Common/Extensions/GRDB+Initialization.swift index 802a61159..aa040c460 100644 --- a/Sources/Shared/Common/Extensions/GRDB+Initialization.swift +++ b/Sources/Shared/Common/Extensions/GRDB+Initialization.swift @@ -7,6 +7,7 @@ public enum GRDBDatabaseTable: String { case assistPipelines case carPlayConfig case clientEvent + case appEntityRegistryListForDisplay } public enum DatabaseTables { @@ -47,6 +48,13 @@ public enum DatabaseTables { case jsonPayload case date } + + public enum AppEntityRegistryListForDisplay: String { + case id + case serverId + case entityId + case registry + } } public extension DatabaseQueue { @@ -85,6 +93,8 @@ public extension DatabaseQueue { var shouldCreateWatchConfig = false var shouldCreateCarPlayConfig = false var shouldCreateClientEvent = false + var shouldCreateAppEntityRegistryListForDisplay = false + do { try database.read { db in shouldCreateHAppEntity = try !db.tableExists(GRDBDatabaseTable.HAAppEntity.rawValue) @@ -92,6 +102,8 @@ public extension DatabaseQueue { shouldCreateCarPlayConfig = try !db.tableExists(GRDBDatabaseTable.carPlayConfig.rawValue) shouldCreateAssistPipelines = try !db.tableExists(GRDBDatabaseTable.assistPipelines.rawValue) shouldCreateClientEvent = try !db.tableExists(GRDBDatabaseTable.clientEvent.rawValue) + shouldCreateAppEntityRegistryListForDisplay = try !db + .tableExists(GRDBDatabaseTable.appEntityRegistryListForDisplay.rawValue) } } catch { let errorMessage = "Failed to check if GRDB tables exist, error: \(error.localizedDescription)" @@ -180,5 +192,23 @@ public extension DatabaseQueue { Current.Log.error(errorMessage) } } + + // AppEntityRegistryListForDisplay + if shouldCreateAppEntityRegistryListForDisplay { + do { + try database.write { db in + try db.create(table: GRDBDatabaseTable.appEntityRegistryListForDisplay.rawValue) { t in + t.primaryKey(DatabaseTables.AppEntityRegistryListForDisplay.id.rawValue, .text).notNull() + t.column(DatabaseTables.AppEntityRegistryListForDisplay.serverId.rawValue, .text).notNull() + t.column(DatabaseTables.AppEntityRegistryListForDisplay.entityId.rawValue, .text).notNull() + t.column(DatabaseTables.AppEntityRegistryListForDisplay.registry.rawValue, .jsonText).notNull() + } + } + } catch { + let errorMessage = + "Failed to create AppEntityRegistryListForDisplay GRDB table, error: \(error.localizedDescription)" + Current.Log.error(errorMessage) + } + } } } diff --git a/Sources/Shared/EntityRegistryListForDisplay.swift b/Sources/Shared/EntityRegistryListForDisplay.swift new file mode 100644 index 000000000..682368b75 --- /dev/null +++ b/Sources/Shared/EntityRegistryListForDisplay.swift @@ -0,0 +1,33 @@ +import Foundation +import GRDB +import HAKit + +public struct EntityRegistryListForDisplay: HADataDecodable { + public let entityCategories: [String: String] + public let entities: [Entity] + + public init(data: HAData) throws { + self.entityCategories = try data.decode("entity_categories") + self.entities = try data.decode("entities") + } + + public struct Entity: HADataDecodable, Codable, FetchableRecord, PersistableRecord { + public let entityId: String + public let entityCategory: Int? + public let decimalPlaces: Int? + + public init(data: HAData) throws { + self.entityId = try data.decode("ei") + self.entityCategory = try? data.decode("ec") + self.decimalPlaces = try? data.decode("dp") + } + } +} + +public struct AppEntityRegistryListForDisplay: Codable, FetchableRecord, PersistableRecord { + /// serverId-entityId + let id: String + let serverId: String + let entityId: String + let registry: EntityRegistryListForDisplay.Entity +} diff --git a/Sources/Shared/Environment/PeriodicAppEntitiesModelUpdater.swift b/Sources/Shared/Environment/PeriodicAppEntitiesModelUpdater.swift index 5ba309320..8f58e3166 100644 --- a/Sources/Shared/Environment/PeriodicAppEntitiesModelUpdater.swift +++ b/Sources/Shared/Environment/PeriodicAppEntitiesModelUpdater.swift @@ -1,4 +1,5 @@ import Foundation +import GRDB import HAKit public protocol PeriodicAppEntitiesModelUpdaterProtocol { @@ -26,6 +27,8 @@ final class PeriodicAppEntitiesModelUpdater: PeriodicAppEntitiesModelUpdaterProt cancelOnGoingRequests() Current.servers.all.forEach { server in guard server.info.connection.activeURL() != nil else { return } + + // Cache entities let requestToken = Current.api(for: server)?.connection.send( HATypedRequest<[HAEntity]>.fetchStates(), completion: { result in @@ -38,6 +41,45 @@ final class PeriodicAppEntitiesModelUpdater: PeriodicAppEntitiesModelUpdaterProt } ) requestTokens.append(requestToken) + + // Cache entities registry list for display + let requestToken2 = Current.api(for: server)?.connection.send( + HATypedRequest.fetchEntityRegistryListForDisplay(), + completion: { [weak self] result in + switch result { + case let .success(response): + self?.saveEntityRegistryListForDisplay(response, serverId: server.identifier.rawValue) + case let .failure(error): + Current.Log.error("Failed to fetch states: \(error)") + } + } + ) + requestTokens.append(requestToken2) + } + } + + private func saveEntityRegistryListForDisplay(_ response: EntityRegistryListForDisplay, serverId: String) { + let entitiesListForDisplay = response.entities.filter({ $0.decimalPlaces != nil || $0.entityCategory != nil }) + .map { registry in + AppEntityRegistryListForDisplay( + id: ServerEntity.uniqueId(serverId: serverId, entityId: registry.entityId), + serverId: serverId, + entityId: registry.entityId, + registry: registry + ) + } + do { + try Current.database.write { db in + try AppEntityRegistryListForDisplay + .filter(Column(DatabaseTables.AppEntityRegistryListForDisplay.serverId.rawValue) == serverId) + .deleteAll(db) + for record in entitiesListForDisplay { + try record.save(db) + } + } + } catch { + Current.Log + .error("Failed to save EntityRegistryListForDisplay in database, error: \(error.localizedDescription)") } } diff --git a/Sources/Shared/HATypedRequest+App.swift b/Sources/Shared/HATypedRequest+App.swift index b75a98e69..3bbfd3986 100644 --- a/Sources/Shared/HATypedRequest+App.swift +++ b/Sources/Shared/HATypedRequest+App.swift @@ -114,4 +114,10 @@ public extension HATypedRequest { type: .rest(.get, "states") )) } + + static func fetchEntityRegistryListForDisplay() -> HATypedRequest { + HATypedRequest(request: .init( + type: .webSocket("config/entity_registry/list_for_display") + )) + } }