mirror of
https://github.com/home-assistant/iOS.git
synced 2026-02-04 02:46:35 -06:00
Refactored custom widget to use AppConstants.createCustomWidgetURL for deeplink construction. Enhanced domain state handling by introducing isActive property and ensuring state comparison is case-insensitive. Updated icon color logic to support additional domains (cover, fan) and improved clarity in state checks. <!-- 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="1342" height="952" alt="CleanShot 2026-01-28 at 10 44 39@2x" src="https://github.com/user-attachments/assets/0d92a047-2356-45b5-8208-423d0b669e4b" /> ## 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 <198982749+Copilot@users.noreply.github.com>
267 lines
9.8 KiB
Swift
267 lines
9.8 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]?
|
|
) {
|
|
guard let attributes else {
|
|
return (nil, nil, nil)
|
|
}
|
|
|
|
let colorMode = attributes["color_mode"] as? String
|
|
let rgbColor = parseRGBColor(from: attributes["rgb_color"])
|
|
let hsColor = parseHSColor(from: attributes["hs_color"])
|
|
|
|
return (colorMode, rgbColor, hsColor)
|
|
}
|
|
|
|
private func parseRGBColor(from value: Any?) -> [Int]? {
|
|
if let rgb = value as? [Int] {
|
|
return rgb
|
|
}
|
|
if let rgbAny = value as? [Any] {
|
|
let ints = rgbAny.compactMap { $0 as? Int }
|
|
return ints.count == 3 ? ints : nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func parseHSColor(from value: Any?) -> [Double]? {
|
|
if let hs = value as? [Double] {
|
|
return hs
|
|
}
|
|
if let hsAny = value as? [Any] {
|
|
let doubles = hsAny.compactMap { value -> Double? in
|
|
if let d = value as? Double { return d }
|
|
if let n = value as? NSNumber { return n.doubleValue }
|
|
if let s = value as? String, let d = Double(s) { return d }
|
|
return nil
|
|
}
|
|
return doubles.count >= 2 ? Array(doubles.prefix(2)) : nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|