Skip to content

Commit 0553c44

Browse files
bgoncalCopilot
andauthored
Add connection troubleshooting to error view (#4245)
<!-- 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. --> <img width="2360" height="1640" alt="Simulator Screenshot - iPad Air 11-inch (M3) - 2026-01-21 at 13 27 07" src="https://github.com/user-attachments/assets/1f7aac7c-2b1b-4252-b81d-e2ca27c40700" /> ## 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. --> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 5f1ae83 commit 0553c44

File tree

8 files changed

+615
-3
lines changed

8 files changed

+615
-3
lines changed

HomeAssistant.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,9 @@
906906
42955C252F1A552A00E398E8 /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420BE1382F0FE50C00E20584 /* ToastView.swift */; };
907907
42955C262F1A552A00E398E8 /* ToastHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420BE1402F0FE5B000E20584 /* ToastHostingController.swift */; };
908908
42955C272F1A552A00E398E8 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420BE1372F0FE50C00E20584 /* Toast.swift */; };
909+
42955C3A2F20E2E800E398E8 /* ConnectivityCheckState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42955C372F20E2E800E398E8 /* ConnectivityCheckState.swift */; };
910+
42955C3B2F20E2E800E398E8 /* ConnectivityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42955C362F20E2E800E398E8 /* ConnectivityChecker.swift */; };
911+
42955C3C2F20E2E800E398E8 /* ConnectivityCheckView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42955C382F20E2E800E398E8 /* ConnectivityCheckView.swift */; };
909912
4296C36D2B90DB640051B63C /* IntentActionAppEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C36B2B90DB630051B63C /* IntentActionAppEntity.swift */; };
910913
4296C36E2B90DB640051B63C /* PerformAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C36C2B90DB630051B63C /* PerformAction.swift */; };
911914
4296C3762B91F0F50051B63C /* WidgetActionsAppIntentTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4296C3742B91F0860051B63C /* WidgetActionsAppIntentTimelineProvider.swift */; };
@@ -2551,6 +2554,9 @@
25512554
429481EA2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewJavascriptCommandsTests.swift; sourceTree = "<group>"; };
25522555
429481EC2DA943E700A8B468 /* ListPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPicker.swift; sourceTree = "<group>"; };
25532556
429481EE2DA94B9900A8B468 /* ListPickerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListPickerTests.swift; sourceTree = "<group>"; };
2557+
42955C362F20E2E800E398E8 /* ConnectivityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityChecker.swift; sourceTree = "<group>"; };
2558+
42955C372F20E2E800E398E8 /* ConnectivityCheckState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityCheckState.swift; sourceTree = "<group>"; };
2559+
42955C382F20E2E800E398E8 /* ConnectivityCheckView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityCheckView.swift; sourceTree = "<group>"; };
25542560
4296C36B2B90DB630051B63C /* IntentActionAppEntity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IntentActionAppEntity.swift; sourceTree = "<group>"; };
25552561
4296C36C2B90DB630051B63C /* PerformAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PerformAction.swift; sourceTree = "<group>"; };
25562562
4296C3742B91F0860051B63C /* WidgetActionsAppIntentTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetActionsAppIntentTimelineProvider.swift; sourceTree = "<group>"; };
@@ -3972,6 +3978,7 @@
39723978
11AD2E2A2528FDB700FBC437 /* WebView */ = {
39733979
isa = PBXGroup;
39743980
children = (
3981+
42955C392F20E2E800E398E8 /* ConnectivityCheck */,
39753982
42D6EABC2F0FF98F00FA249B /* ExternalMessageBus */,
39763983
11EFCDD224F5F39100314D85 /* WebViewWindowController.swift */,
39773984
11DE822D24FAC51000E636B8 /* IncomingURLHandler.swift */,
@@ -5323,6 +5330,16 @@
53235330
path = Audio;
53245331
sourceTree = "<group>";
53255332
};
5333+
42955C392F20E2E800E398E8 /* ConnectivityCheck */ = {
5334+
isa = PBXGroup;
5335+
children = (
5336+
42955C362F20E2E800E398E8 /* ConnectivityChecker.swift */,
5337+
42955C372F20E2E800E398E8 /* ConnectivityCheckState.swift */,
5338+
42955C382F20E2E800E398E8 /* ConnectivityCheckView.swift */,
5339+
);
5340+
path = ConnectivityCheck;
5341+
sourceTree = "<group>";
5342+
};
53265343
4296C36A2B90DB630051B63C /* AppIntents */ = {
53275344
isa = PBXGroup;
53285345
children = (
@@ -9158,6 +9175,9 @@
91589175
1127383C2625512600F5E312 /* ButtonRowWithLoading.swift in Sources */,
91599176
42ABB0BB2C888BB10081461D /* CarPlayConfigurationViewModel.swift in Sources */,
91609177
42070EE82BAC43240031E96F /* AssistSession.swift in Sources */,
9178+
42955C3A2F20E2E800E398E8 /* ConnectivityCheckState.swift in Sources */,
9179+
42955C3B2F20E2E800E398E8 /* ConnectivityChecker.swift in Sources */,
9180+
42955C3C2F20E2E800E398E8 /* ConnectivityCheckView.swift in Sources */,
91619181
427FEE092D9C04050047C00C /* OnboardingServersListViewModel.swift in Sources */,
91629182
420FE84E2B556CE500878E06 /* CarPlayEntitiesListViewModel.swift in Sources */,
91639183
4263A6662EFB291D0089C338 /* LightIntent.swift in Sources */,

Sources/App/Resources/en.lproj/Localizable.strings

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,4 +1454,17 @@ Home Assistant is open source, advocates for privacy and runs locally in your ho
14541454
"database_updater.toast.title" = "Updating %@";
14551455
"database_updater.toast.syncing" = "Syncing server data...";
14561456
"database_updater.toast.syncing_with_progress" = "Syncing server data... (%d/%d)";
1457+
"connectivity.diagnostics.title" = "Connectivity diagnostics";
1458+
"connectivity.diagnostics.run_checks" = "Run checks";
1459+
"connectivity.diagnostics.start" = "Start diagnostics";
1460+
"connectivity.check.dns" = "DNS Resolution";
1461+
"connectivity.check.dns.description" = "Resolving hostname to IP address";
1462+
"connectivity.check.port" = "Port Reachability";
1463+
"connectivity.check.port.description" = "Checking if port is reachable";
1464+
"connectivity.check.tls" = "TLS Certificate";
1465+
"connectivity.check.tls.description" = "Validating TLS certificate";
1466+
"connectivity.check.server" = "Server Connection";
1467+
"connectivity.check.server.description" = "Testing server connection";
1468+
"connectivity.check.running" = "Checking...";
1469+
"connectivity.check.skipped" = "Skipped due to previous failure";
14571470
"settings.connection_section.refresh_server" = "Update server information";
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import Foundation
2+
import Shared
3+
4+
enum ConnectivityCheckType: String, CaseIterable {
5+
case dns = "DNS Resolution"
6+
case port = "Port Reachability"
7+
case tls = "TLS Certificate"
8+
case server = "Server Connection"
9+
10+
var localizedName: String {
11+
switch self {
12+
case .dns:
13+
return L10n.Connectivity.Check.dns
14+
case .port:
15+
return L10n.Connectivity.Check.port
16+
case .tls:
17+
return L10n.Connectivity.Check.tls
18+
case .server:
19+
return L10n.Connectivity.Check.server
20+
}
21+
}
22+
}
23+
24+
enum ConnectivityCheckResult: Equatable {
25+
case pending
26+
case running
27+
case success(message: String?)
28+
case failure(error: String)
29+
case skipped
30+
31+
var isCompleted: Bool {
32+
switch self {
33+
case .success, .failure, .skipped:
34+
return true
35+
case .pending, .running:
36+
return false
37+
}
38+
}
39+
}
40+
41+
struct ConnectivityCheck: Identifiable {
42+
let id = UUID()
43+
let type: ConnectivityCheckType
44+
var result: ConnectivityCheckResult
45+
46+
init(type: ConnectivityCheckType, result: ConnectivityCheckResult = .pending) {
47+
self.type = type
48+
self.result = result
49+
}
50+
}
51+
52+
class ConnectivityCheckState: ObservableObject {
53+
@Published var checks: [ConnectivityCheck] = []
54+
@Published var isRunning: Bool = false
55+
56+
init() {
57+
self.checks = ConnectivityCheckType.allCases.map { ConnectivityCheck(type: $0) }
58+
}
59+
60+
func updateCheck(type: ConnectivityCheckType, result: ConnectivityCheckResult) {
61+
if let index = checks.firstIndex(where: { $0.type == type }) {
62+
checks[index].result = result
63+
}
64+
}
65+
66+
func reset() {
67+
checks = ConnectivityCheckType.allCases.map { ConnectivityCheck(type: $0) }
68+
isRunning = false
69+
}
70+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import SFSafeSymbols
2+
import Shared
3+
import SwiftUI
4+
5+
struct ConnectivityCheckView: View {
6+
@ObservedObject var state: ConnectivityCheckState
7+
let url: URL
8+
let onRunChecks: () -> Void
9+
10+
var body: some View {
11+
VStack(alignment: .leading, spacing: DesignSystem.Spaces.two) {
12+
HStack {
13+
Text(L10n.Connectivity.Diagnostics.title)
14+
.font(.headline.bold())
15+
Spacer()
16+
if !state.isRunning {
17+
Button(action: onRunChecks) {
18+
HStack(spacing: 4) {
19+
Image(systemSymbol: .arrowClockwise)
20+
.font(.caption)
21+
Text(L10n.Connectivity.Diagnostics.runChecks)
22+
.font(.caption.bold())
23+
}
24+
.foregroundStyle(.blue)
25+
}
26+
}
27+
}
28+
29+
if state.checks.allSatisfy({ $0.result == .pending }) {
30+
Button(action: onRunChecks) {
31+
Label(L10n.Connectivity.Diagnostics.start, systemSymbol: .stethoscope)
32+
}
33+
.buttonStyle(.primaryButton)
34+
.frame(maxWidth: .infinity, alignment: .center)
35+
} else {
36+
VStack(alignment: .leading, spacing: DesignSystem.Spaces.oneAndHalf) {
37+
ForEach(state.checks) { check in
38+
ConnectivityCheckRow(check: check)
39+
}
40+
}
41+
}
42+
}
43+
.padding(.vertical)
44+
}
45+
}
46+
47+
struct ConnectivityCheckRow: View {
48+
let check: ConnectivityCheck
49+
50+
var body: some View {
51+
HStack(spacing: DesignSystem.Spaces.one) {
52+
statusIcon
53+
.frame(width: 24, height: 24)
54+
55+
VStack(alignment: .leading, spacing: 2) {
56+
Text(check.type.localizedName)
57+
.font(.body.bold())
58+
59+
if case let .success(message) = check.result, let message {
60+
Text(message)
61+
.font(.caption)
62+
.foregroundStyle(.secondary)
63+
} else if case let .failure(error) = check.result {
64+
Text(error)
65+
.font(.caption)
66+
.foregroundStyle(.red)
67+
} else if check.result == .skipped {
68+
Text(L10n.Connectivity.Check.skipped)
69+
.font(.caption)
70+
.foregroundStyle(.secondary)
71+
} else if check.result == .running {
72+
Text(L10n.Connectivity.Check.running)
73+
.font(.caption)
74+
.foregroundStyle(.secondary)
75+
}
76+
}
77+
78+
Spacer()
79+
}
80+
.padding(.vertical, 4)
81+
}
82+
83+
@ViewBuilder
84+
private var statusIcon: some View {
85+
switch check.result {
86+
case .pending:
87+
Image(systemSymbol: .circle)
88+
.foregroundStyle(.gray)
89+
case .running:
90+
ProgressView()
91+
case .success:
92+
Image(systemSymbol: .checkmarkCircleFill)
93+
.foregroundStyle(.green)
94+
case .failure:
95+
Image(systemSymbol: .xmarkCircleFill)
96+
.foregroundStyle(.red)
97+
case .skipped:
98+
Image(systemSymbol: .minusCircleFill)
99+
.foregroundStyle(.gray)
100+
}
101+
}
102+
}
103+
104+
#Preview("Pending") {
105+
ConnectivityCheckView(
106+
state: ConnectivityCheckState(),
107+
url: URL(string: "https://example.com")!,
108+
onRunChecks: {}
109+
)
110+
.padding()
111+
}
112+
113+
#Preview("Running") {
114+
let state = ConnectivityCheckState()
115+
state.updateCheck(type: .dns, result: .success(message: "Resolved to 93.184.216.34"))
116+
state.updateCheck(type: .port, result: .running)
117+
state.isRunning = true
118+
119+
return ConnectivityCheckView(
120+
state: state,
121+
url: URL(string: "https://example.com")!,
122+
onRunChecks: {}
123+
)
124+
.padding()
125+
}
126+
127+
#Preview("Success") {
128+
let state = ConnectivityCheckState()
129+
state.updateCheck(type: .dns, result: .success(message: "Resolved to 93.184.216.34"))
130+
state.updateCheck(type: .port, result: .success(message: "Port 443 is reachable"))
131+
state.updateCheck(type: .tls, result: .success(message: "Certificate is valid"))
132+
state.updateCheck(type: .server, result: .success(message: "Server responded with status 200"))
133+
134+
return ConnectivityCheckView(
135+
state: state,
136+
url: URL(string: "https://example.com")!,
137+
onRunChecks: {}
138+
)
139+
.padding()
140+
}
141+
142+
#Preview("Failure") {
143+
let state = ConnectivityCheckState()
144+
state.updateCheck(type: .dns, result: .success(message: "Resolved to 93.184.216.34"))
145+
state.updateCheck(type: .port, result: .failure(error: "Connection timeout"))
146+
state.updateCheck(type: .tls, result: .skipped)
147+
state.updateCheck(type: .server, result: .skipped)
148+
149+
return ConnectivityCheckView(
150+
state: state,
151+
url: URL(string: "https://example.com")!,
152+
onRunChecks: {}
153+
)
154+
.padding()
155+
}

0 commit comments

Comments
 (0)