Files
iOS/Sources/Shared/ControlEntityProvider.swift
Bruno Pantaleão Gonçalves 3613bb8721 CarPlay iOS 26 UI Improvements (#4465)
<!-- 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="800" height="480" alt="Simulator Screenshot - Daily tester 2
- 2026-03-31 at 16 45 28"
src="https://github.com/user-attachments/assets/927764c8-9de7-49e9-98c4-8cc8fd0bcf6b"
/>

## 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. -->
2026-04-01 09:39:44 +02:00

232 lines
8.6 KiB
Swift

import Foundation
import GRDB
import HAKit
import SwiftUI
public final class ControlEntityProvider {
public enum States: String {
case open
case opening
case close
case closing
case on
case off
}
public struct State {
public let value: String
public let unitOfMeasurement: String?
public let domainState: Domain.State?
public let color: Color?
public init(value: String, unitOfMeasurement: String?, domainState: Domain.State?, color: Color? = nil) {
self.value = value
self.unitOfMeasurement = unitOfMeasurement
self.domainState = domainState
self.color = color
}
}
public let domains: [Domain]
public init(domains: [Domain]) {
self.domains = domains
}
public func currentState(serverId: String, entityId: String) async throws -> String? {
guard let server = Current.servers.all.first(where: { $0.identifier.rawValue == serverId }),
let connection = Current.api(for: server)?.connection else {
return nil
}
let state: String? = await withCheckedContinuation { continuation in
connection.send(.init(
type: .rest(.get, "states/\(entityId)")
)) { result in
switch result {
case let .success(data):
let state: String? = data.decode("state", fallback: nil)
continuation.resume(returning: state)
case let .failure(error):
Current.Log.error("Failed to get \(entityId) state for ControlEntityProvider: \(error)")
continuation.resume(returning: nil)
}
}
}
return state
}
public func getEntities(matching string: String? = nil) -> [(Server, [HAAppEntity])] {
var entitiesPerServer: [(Server, [HAAppEntity])] = []
for server in Current.servers.all.sorted(by: { $0.info.name < $1.info.name }) {
do {
var entities: [HAAppEntity] = try Current.database().read { db in
if domains.isEmpty {
try HAAppEntity
.filter(Column(DatabaseTables.AppEntity.serverId.rawValue) == server.identifier.rawValue)
.fetchAll(db)
} else {
try HAAppEntity
.filter(Column(DatabaseTables.AppEntity.serverId.rawValue) == server.identifier.rawValue)
.filter(domains.map(\.rawValue).contains(Column(DatabaseTables.AppEntity.domain.rawValue)))
.fetchAll(db)
}
}
if let string {
let deviceMap = entities.devicesMap(for: server.identifier.rawValue)
let areasMap = entities.areasMap(for: server.identifier.rawValue)
entities = entities.filter({ entity in
let matchName = entity.name.range(
of: string,
options: [.caseInsensitive, .diacriticInsensitive]
) != nil
let matchEntityId = entity.entityId.range(
of: string,
options: [.caseInsensitive, .diacriticInsensitive]
) != nil
let matchDeviceName = {
if let deviceName = deviceMap[entity.entityId]?.name {
return deviceName
.range(of: string, options: [.caseInsensitive, .diacriticInsensitive]) != nil
} else {
return false
}
}()
let matchAreaName = {
if let areaName = areasMap[entity.entityId]?.name {
return areaName
.range(of: string, options: [.caseInsensitive, .diacriticInsensitive]) != nil
} else {
return false
}
}()
return matchName || matchEntityId || matchDeviceName || matchAreaName
})
}
entitiesPerServer.append((server, entities))
} catch {
Current.Log.error("Failed to load entities from database: \(error.localizedDescription)")
}
}
return entitiesPerServer
}
public func state(server: Server, entityId: String) async -> State? {
guard let connection = Current.api(for: server)?.connection else {
Current.Log.error("No API available to fetch state data")
return nil
}
let result = await withCheckedContinuation { continuation in
connection.send(.init(
type: .rest(.get, "states/\(entityId)"),
shouldRetry: true
)) { result in
continuation.resume(returning: result)
}
}
guard let data = try? result.get() else {
if case let .failure(error) = result {
Current.Log.error("Failed to get state: \(error)")
}
return nil
}
guard case let .dictionary(state) = data else {
Current.Log.error("Failed to get state bad response data")
return nil
}
var stateValue = (state["state"] as? String) ?? "N/A"
stateValue = StatePrecision.adjustPrecision(
serverId: server.identifier.rawValue,
entityId: entityId,
stateValue: stateValue
)
stateValue = stateValue.capitalizedFirst
let attributes = state["attributes"] as? [String: Any]
let colorAttributes = parseColorAttributes(from: attributes)
let unitOfMeasurement = attributes?["unit_of_measurement"] as? String
return buildState(
entityId: entityId,
stateValue: stateValue,
attributes: attributes,
colorAttributes: colorAttributes,
unitOfMeasurement: unitOfMeasurement
)
}
private func parseColorAttributes(from attributes: [String: Any]?) -> (
colorMode: String?,
rgbColor: [Int]?,
hsColor: [Double]?
) {
EntityColorAttributesParser.parse(from: attributes)
}
private func buildState(
entityId: String,
stateValue: String,
attributes: [String: Any]?,
colorAttributes: (colorMode: String?, rgbColor: [Int]?, hsColor: [Double]?),
unitOfMeasurement: String?
) -> State {
let domain = Domain(entityId: entityId)
let domainState = Domain.State(rawValue: stateValue.lowercased())
if let deviceClass = extractDeviceClass(from: attributes),
let domainState,
unitOfMeasurement == nil,
let stateForDeviceClass = domain?.stateForDeviceClass(deviceClass, state: domainState) {
let computedColor = computeIconColor(
entityId: entityId,
stateValue: stateValue,
colorAttributes: colorAttributes
)
return .init(
value: stateForDeviceClass,
unitOfMeasurement: nil,
domainState: domainState,
color: computedColor
)
} else {
let computedColor = computeIconColor(
entityId: entityId,
stateValue: stateValue,
colorAttributes: colorAttributes
)
return .init(
value: stateValue,
unitOfMeasurement: unitOfMeasurement,
domainState: domainState,
color: computedColor
)
}
}
private func extractDeviceClass(from attributes: [String: Any]?) -> DeviceClass? {
guard let rawDeviceClass = attributes?["device_class"] as? String else {
return nil
}
return DeviceClass(rawValue: rawDeviceClass)
}
private func computeIconColor(
entityId: String,
stateValue: String,
colorAttributes: (colorMode: String?, rgbColor: [Int]?, hsColor: [Double]?)
) -> Color? {
EntityIconColorProvider.iconColor(
domain: Domain(entityId: entityId) ?? .switch,
state: stateValue.lowercased(),
colorMode: colorAttributes.colorMode,
rgbColor: colorAttributes.rgbColor,
hsColor: colorAttributes.hsColor
)
}
}