Skip to content

Commit 39be820

Browse files
authored
Persist Entity and Device registry (#4157)
<!-- 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. -->
1 parent 8cdfb4f commit 39be820

26 files changed

+164874
-214
lines changed

HomeAssistant.xcodeproj/project.pbxproj

Lines changed: 54 additions & 0 deletions
Large diffs are not rendered by default.

Sources/App/Settings/MagicItem/Add/MagicItemAddViewModel.swift

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,22 @@ final class MagicItemAddViewModel: ObservableObject {
5555
}
5656

5757
func subtitleForEntity(entity: HAAppEntity, serverId: String) -> String {
58-
// Fetch area from database based on entity
59-
do {
60-
let areas = try AppArea.fetchAreas(containingEntity: entity.entityId, serverId: serverId)
61-
if let area = areas.first {
62-
return area.name
58+
var subtitle = ""
59+
if let areaName = entity.area?.name, !areaName.isEmpty {
60+
subtitle = areaName
61+
}
62+
if let deviceName = entity.device?.name,
63+
!deviceName.isEmpty,
64+
deviceName.range(of: entity.name, options: [.caseInsensitive, .diacriticInsensitive]) == nil {
65+
if !subtitle.isEmpty {
66+
subtitle += ""
6367
}
64-
} catch {
65-
Current.Log.error("Failed to fetch area for entity from database: \(error.localizedDescription)")
68+
subtitle += deviceName
69+
}
70+
if subtitle.isEmpty {
71+
subtitle = entity.entityId
6672
}
67-
return entity.entityId
73+
return subtitle
6874
}
6975

7076
@MainActor

Sources/App/Settings/MagicItem/EntityPickerViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ final class EntityPickerViewModel: ObservableObject {
1515

1616
func fetchEntities() {
1717
do {
18-
var newEntities = try HAAppEntity.config() ?? []
18+
var newEntities = try HAAppEntity.config()
1919
if let domainFilter {
2020
newEntities = newEntities.filter({ entity in
2121
entity.domain == domainFilter.rawValue
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import Foundation
2+
import GRDB
3+
4+
public extension HAAppEntity {
5+
var area: AppArea? {
6+
do {
7+
let areas = try AppArea.fetchAreas(for: serverId)
8+
9+
return areas.first { area in
10+
area.entities.contains(entityId)
11+
}
12+
} catch {
13+
Current.Log.error("Failed to fetch areas for entity \(entityId): \(error)")
14+
return nil
15+
}
16+
}
17+
18+
var device: AppDeviceRegistry? {
19+
do {
20+
let entityRegistry = try Current.database().read { db in
21+
try AppEntityRegistry
22+
.filter(Column(DatabaseTables.EntityRegistry.serverId.rawValue) == serverId)
23+
.filter(Column(DatabaseTables.EntityRegistry.entityId.rawValue) == entityId)
24+
.fetchOne(db)
25+
}
26+
let deviceId = entityRegistry?.deviceId
27+
let device = try Current.database().read { db in
28+
try AppDeviceRegistry
29+
.filter(Column(DatabaseTables.DeviceRegistry.serverId.rawValue) == serverId)
30+
.filter(Column(DatabaseTables.DeviceRegistry.deviceId.rawValue) == deviceId)
31+
.fetchOne(db)
32+
}
33+
return device
34+
} catch {
35+
Current.Log.error("Failed to fetch device for entity \(entityId): \(error)")
36+
return nil
37+
}
38+
}
39+
}
40+
41+
public extension [HAAppEntity] {
42+
/// Creates a mapping from entity IDs to their associated areas for a given server.
43+
/// - Parameter serverId: The server identifier to filter areas by.
44+
/// - Returns: A dictionary mapping entity IDs to their corresponding `AppArea` objects.
45+
func areasMap(for serverId: String) -> [String: AppArea] {
46+
do {
47+
let areas = try AppArea.fetchAreas(for: serverId)
48+
49+
var entityToAreaMap: [String: AppArea] = [:]
50+
51+
// Iterate through areas and map each entity to its area
52+
for area in areas {
53+
for entityId in area.entities {
54+
entityToAreaMap[entityId] = area
55+
}
56+
}
57+
58+
return entityToAreaMap
59+
} catch {
60+
Current.Log.error("Failed to fetch areas for mapping: \(error)")
61+
return [:]
62+
}
63+
}
64+
65+
/// Creates a mapping from entity IDs to their associated devices for a given server.
66+
/// - Parameter serverId: The server identifier to filter entities and devices by.
67+
/// - Returns: A dictionary mapping entity IDs to their corresponding `AppDeviceRegistry` objects.
68+
func devicesMap(for serverId: String) -> [String: AppDeviceRegistry] {
69+
do {
70+
// Fetch all entity registries for the server
71+
let entityRegistries = try Current.database().read { db in
72+
try AppEntityRegistry
73+
.filter(Column(DatabaseTables.EntityRegistry.serverId.rawValue) == serverId)
74+
.fetchAll(db)
75+
}
76+
77+
// Fetch all devices for the server
78+
let devices = try Current.database().read { db in
79+
try AppDeviceRegistry
80+
.filter(Column(DatabaseTables.DeviceRegistry.serverId.rawValue) == serverId)
81+
.fetchAll(db)
82+
}
83+
84+
// Create device lookup by deviceId
85+
let devicesByDeviceId = Dictionary(uniqueKeysWithValues: devices.map { ($0.deviceId, $0) })
86+
87+
// Map entity IDs to devices
88+
var entityToDeviceMap: [String: AppDeviceRegistry] = [:]
89+
90+
for entityRegistry in entityRegistries {
91+
guard let entityId = entityRegistry.entityId,
92+
let deviceId = entityRegistry.deviceId,
93+
let device = devicesByDeviceId[deviceId] else {
94+
continue
95+
}
96+
entityToDeviceMap[entityId] = device
97+
}
98+
99+
return entityToDeviceMap
100+
} catch {
101+
Current.Log.error("Failed to fetch devices for mapping: \(error)")
102+
return [:]
103+
}
104+
}
105+
}

Sources/Extensions/Widgets/Controls/Cover/IntentCoverEntity.swift

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,50 @@ struct IntentCoverEntity: AppEntity {
1414
var id: String
1515
var entityId: String
1616
var serverId: String
17+
var areaName: String?
18+
var deviceName: String?
1719
var displayString: String
1820
var iconName: String
1921
var displayRepresentation: DisplayRepresentation {
20-
DisplayRepresentation(title: "\(displayString)")
22+
DisplayRepresentation(
23+
title: "\(displayString)",
24+
subtitle: .init(stringLiteral: subtitle)
25+
)
26+
}
27+
28+
private var subtitle: String {
29+
var subtitle = ""
30+
if let areaName {
31+
subtitle += areaName
32+
}
33+
34+
if let deviceName,
35+
deviceName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != displayString.lowercased()
36+
.trimmingCharacters(in: .whitespacesAndNewlines) {
37+
if subtitle.isEmpty {
38+
subtitle += deviceName
39+
} else {
40+
subtitle += "\(deviceName)"
41+
}
42+
}
43+
44+
return subtitle
2145
}
2246

2347
init(
2448
id: String,
2549
entityId: String,
2650
serverId: String,
51+
areaName: String? = nil,
52+
deviceName: String? = nil,
2753
displayString: String,
2854
iconName: String
2955
) {
3056
self.id = id
3157
self.entityId = entityId
3258
self.serverId = serverId
59+
self.areaName = areaName
60+
self.deviceName = deviceName
3361
self.displayString = displayString
3462
self.iconName = iconName
3563
}
@@ -42,12 +70,12 @@ struct IntentCoverAppEntityQuery: EntityQuery, EntityStringQuery {
4270
}
4371

4472
func entities(matching string: String) async throws -> IntentItemCollection<IntentCoverEntity> {
45-
let CoveresPerServer = await getCoverEntities()
73+
let CoveresPerServer = await getCoverEntities(matching: string)
4674

4775
return .init(sections: CoveresPerServer.map { (key: Server, value: [IntentCoverEntity]) in
4876
.init(
4977
.init(stringLiteral: key.info.name),
50-
items: value.filter({ $0.displayString.lowercased().contains(string.lowercased()) })
78+
items: value
5179
)
5280
})
5381
}
@@ -65,11 +93,15 @@ struct IntentCoverAppEntityQuery: EntityQuery, EntityStringQuery {
6593
let entities = ControlEntityProvider(domains: [.cover]).getEntities(matching: string)
6694

6795
for (server, values) in entities {
96+
let deviceMap = values.devicesMap(for: server.identifier.rawValue)
97+
let areasMap = values.areasMap(for: server.identifier.rawValue)
6898
coverEntities.append((server, values.map({ entity in
6999
IntentCoverEntity(
70100
id: entity.id,
71101
entityId: entity.entityId,
72102
serverId: entity.serverId,
103+
areaName: areasMap[entity.entityId]?.name,
104+
deviceName: deviceMap[entity.entityId]?.name,
73105
displayString: entity.name,
74106
iconName: entity.icon ?? SFSymbol.blindsVerticalOpen.rawValue
75107
)

Sources/Extensions/Widgets/Controls/Fan/IntentFanEntity.swift

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,50 @@ struct IntentFanEntity: AppEntity {
1414
var id: String
1515
var entityId: String
1616
var serverId: String
17+
var areaName: String?
18+
var deviceName: String?
1719
var displayString: String
1820
var iconName: String
1921
var displayRepresentation: DisplayRepresentation {
20-
DisplayRepresentation(title: "\(displayString)")
22+
DisplayRepresentation(
23+
title: "\(displayString)",
24+
subtitle: .init(stringLiteral: subtitle)
25+
)
26+
}
27+
28+
private var subtitle: String {
29+
var subtitle = ""
30+
if let areaName {
31+
subtitle += areaName
32+
}
33+
34+
if let deviceName,
35+
deviceName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != displayString.lowercased()
36+
.trimmingCharacters(in: .whitespacesAndNewlines) {
37+
if subtitle.isEmpty {
38+
subtitle += deviceName
39+
} else {
40+
subtitle += "\(deviceName)"
41+
}
42+
}
43+
44+
return subtitle
2145
}
2246

2347
init(
2448
id: String,
2549
entityId: String,
2650
serverId: String,
51+
areaName: String? = nil,
52+
deviceName: String? = nil,
2753
displayString: String,
2854
iconName: String
2955
) {
3056
self.id = id
3157
self.entityId = entityId
3258
self.serverId = serverId
59+
self.areaName = areaName
60+
self.deviceName = deviceName
3361
self.displayString = displayString
3462
self.iconName = iconName
3563
}
@@ -42,12 +70,12 @@ struct IntentFanAppEntityQuery: EntityQuery, EntityStringQuery {
4270
}
4371

4472
func entities(matching string: String) async throws -> IntentItemCollection<IntentFanEntity> {
45-
let fansPerServer = await getFanEntities()
73+
let fansPerServer = await getFanEntities(matching: string)
4674

4775
return .init(sections: fansPerServer.map { (key: Server, value: [IntentFanEntity]) in
4876
.init(
4977
.init(stringLiteral: key.info.name),
50-
items: value.filter({ $0.displayString.lowercased().contains(string.lowercased()) })
78+
items: value
5179
)
5280
})
5381
}
@@ -65,11 +93,15 @@ struct IntentFanAppEntityQuery: EntityQuery, EntityStringQuery {
6593
let entities = ControlEntityProvider(domains: [.fan]).getEntities(matching: string)
6694

6795
for (server, values) in entities {
96+
let deviceMap = values.devicesMap(for: server.identifier.rawValue)
97+
let areasMap = values.areasMap(for: server.identifier.rawValue)
6898
fanEntities.append((server, values.map({ entity in
6999
IntentFanEntity(
70100
id: entity.id,
71101
entityId: entity.entityId,
72102
serverId: entity.serverId,
103+
areaName: areasMap[entity.entityId]?.name,
104+
deviceName: deviceMap[entity.entityId]?.name,
73105
displayString: entity.name,
74106
iconName: entity.icon ?? SFSymbol.fan.rawValue
75107
)

Sources/Extensions/Widgets/Controls/Light/IntentLightEntity.swift

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,50 @@ struct IntentLightEntity: AppEntity {
1414
var id: String
1515
var entityId: String
1616
var serverId: String
17+
var areaName: String?
18+
var deviceName: String?
1719
var displayString: String
1820
var iconName: String
1921
var displayRepresentation: DisplayRepresentation {
20-
DisplayRepresentation(title: "\(displayString)")
22+
DisplayRepresentation(
23+
title: "\(displayString)",
24+
subtitle: .init(stringLiteral: subtitle)
25+
)
26+
}
27+
28+
private var subtitle: String {
29+
var subtitle = ""
30+
if let areaName {
31+
subtitle += areaName
32+
}
33+
34+
if let deviceName,
35+
deviceName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != displayString.lowercased()
36+
.trimmingCharacters(in: .whitespacesAndNewlines) {
37+
if subtitle.isEmpty {
38+
subtitle += deviceName
39+
} else {
40+
subtitle += "\(deviceName)"
41+
}
42+
}
43+
44+
return subtitle
2145
}
2246

2347
init(
2448
id: String,
2549
entityId: String,
2650
serverId: String,
51+
areaName: String? = nil,
52+
deviceName: String? = nil,
2753
displayString: String,
2854
iconName: String
2955
) {
3056
self.id = id
3157
self.entityId = entityId
3258
self.serverId = serverId
59+
self.areaName = areaName
60+
self.deviceName = deviceName
3361
self.displayString = displayString
3462
self.iconName = iconName
3563
}
@@ -42,12 +70,12 @@ struct IntentLightAppEntityQuery: EntityQuery, EntityStringQuery {
4270
}
4371

4472
func entities(matching string: String) async throws -> IntentItemCollection<IntentLightEntity> {
45-
let lightsPerServer = getLightEntities()
73+
let lightsPerServer = getLightEntities(matching: string)
4674

4775
return .init(sections: lightsPerServer.map { (key: Server, value: [IntentLightEntity]) in
4876
.init(
4977
.init(stringLiteral: key.info.name),
50-
items: value.filter({ $0.displayString.lowercased().contains(string.lowercased()) })
78+
items: value
5179
)
5280
})
5381
}
@@ -65,11 +93,15 @@ struct IntentLightAppEntityQuery: EntityQuery, EntityStringQuery {
6593
let entities = ControlEntityProvider(domains: [.light]).getEntities(matching: string)
6694

6795
for (server, values) in entities {
96+
let deviceMap = values.devicesMap(for: server.identifier.rawValue)
97+
let areasMap = values.areasMap(for: server.identifier.rawValue)
6898
lightEntities.append((server, values.map({ entity in
6999
IntentLightEntity(
70100
id: entity.id,
71101
entityId: entity.entityId,
72102
serverId: entity.serverId,
103+
areaName: areasMap[entity.entityId]?.name,
104+
deviceName: deviceMap[entity.entityId]?.name,
73105
displayString: entity.name,
74106
iconName: entity.icon ?? SFSymbol.lightbulbFill.rawValue
75107
)

0 commit comments

Comments
 (0)