Files
iOS/Sources/Shared/API/Models/RealmZone.swift
Bruno Pantaleão Gonçalves 918d3affec Add "in_zones" array when reporting zone-only location (#4665)
<!-- 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 -->
To fulfill the new requirements in core we need to send in_zones when we
send a location update using zone only. See
https://github.com/home-assistant/architecture/discussions/1387 for more
details. Implemented in core
https://github.com/home-assistant/core/pull/171814

Android PR: https://github.com/home-assistant/android/pull/6879
## 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. -->

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-03 11:16:32 +02:00

276 lines
9.0 KiB
Swift

import CoreLocation
import Foundation
import HAKit
import RealmSwift
private extension HAEntityAttributes {
// app-specific attributes for zones, always optional
var isTrackingEnabled: Bool { self["track_ios"] as? Bool ?? true }
var beaconUUID: String? { beacon["uuid"] as? String }
var beaconMajor: Int? { beacon["major"] as? Int }
var beaconMinor: Int? { beacon["minor"] as? Int }
var ssidTrigger: [String] { self["ssid_trigger"] as? [String] ?? [] }
var ssidFilter: [String] { self["ssid_filter"] as? [String] ?? [] }
private var beacon: [String: Any] { self["beacon"] as? [String: Any] ?? [:] }
}
public final class RLMZone: Object, UpdatableModel {
@objc public dynamic var identifier: String = ""
@objc public dynamic var entityId: String = "" {
didSet {
identifier = Self.primaryKey(sourceIdentifier: entityId, serverIdentifier: serverIdentifier)
}
}
@objc public dynamic var serverIdentifier: String = "" {
didSet {
identifier = Self.primaryKey(sourceIdentifier: entityId, serverIdentifier: serverIdentifier)
}
}
@objc public dynamic var FriendlyName: String?
@objc public dynamic var Latitude: Double = 0.0
@objc public dynamic var Longitude: Double = 0.0
@objc public dynamic var Radius: Double = 0.0
@objc public dynamic var TrackingEnabled = true
@objc public dynamic var enterNotification = true
@objc public dynamic var exitNotification = true
@objc public dynamic var inRegion = false
@objc public dynamic var isPassive = false
// Beacons
@objc public dynamic var BeaconUUID: String?
public let BeaconMajor = RealmProperty<Int?>()
public let BeaconMinor = RealmProperty<Int?>()
// SSID
public var SSIDTrigger = List<String>()
public var SSIDFilter = List<String>()
static func primaryKey(sourceIdentifier: String, serverIdentifier: String) -> String {
serverIdentifier + "/" + sourceIdentifier
}
public var isHome: Bool {
entityId == "zone.home"
}
static func didUpdate(objects: [RLMZone], server: Server, realm: Realm) {}
static func willDelete(objects: [RLMZone], server: Server?, realm: Realm) {}
func update(with zone: HAEntity, server: Server, using: Realm) -> Bool {
guard let zoneAttributes = zone.attributes.zone else {
return false
}
if realm == nil {
entityId = zone.entityId
} else {
precondition(zone.entityId == entityId)
}
serverIdentifier = server.identifier.rawValue
FriendlyName = zone.attributes.friendlyName
Latitude = zoneAttributes.latitude
Longitude = zoneAttributes.longitude
Radius = zoneAttributes.radius.converted(to: .meters).value
isPassive = zoneAttributes.isPassive
// app-specific attributes
TrackingEnabled = zone.attributes.isTrackingEnabled
BeaconUUID = zone.attributes.beaconUUID
BeaconMajor.value = zone.attributes.beaconMajor
BeaconMinor.value = zone.attributes.beaconMinor
SSIDTrigger.removeAll()
SSIDTrigger.append(objectsIn: zone.attributes.ssidTrigger)
SSIDFilter.removeAll()
SSIDFilter.append(objectsIn: zone.attributes.ssidFilter)
return true
}
public static var trackablePredicate: NSPredicate {
.init(format: "TrackingEnabled == true && isPassive == false")
}
public var center: CLLocationCoordinate2D {
.init(
latitude: Latitude,
longitude: Longitude
)
}
public var location: CLLocation {
CLLocation(
coordinate: center,
altitude: 0,
horizontalAccuracy: Radius,
verticalAccuracy: -1,
timestamp: Date()
)
}
public var regionsForMonitoring: [CLRegion] {
#if os(iOS)
if let beaconRegion {
return [beaconRegion]
} else {
return circularRegionsForMonitoring
}
#else
return circularRegionsForMonitoring
#endif
}
public var circularRegion: CLCircularRegion {
let region = CLCircularRegion(center: center, radius: Radius, identifier: identifier)
region.notifyOnEntry = true
region.notifyOnExit = true
return region
}
#if os(iOS)
public var beaconRegion: CLBeaconRegion? {
guard let uuidString = BeaconUUID else {
return nil
}
guard let uuid = UUID(uuidString: uuidString) else {
let event =
ClientEvent(
text: "Unable to create beacon region due to invalid UUID: \(uuidString)",
type: .locationUpdate
)
Current.clientEventStore.addEvent(event)
return nil
}
let beaconRegion: CLBeaconRegion
if let major = BeaconMajor.value, let minor = BeaconMinor.value {
beaconRegion = CLBeaconRegion(
uuid: uuid,
major: CLBeaconMajorValue(major),
minor: CLBeaconMinorValue(minor),
identifier: identifier
)
} else if let major = BeaconMajor.value {
beaconRegion = CLBeaconRegion(
uuid: uuid,
major: CLBeaconMajorValue(major),
identifier: identifier
)
} else {
beaconRegion = CLBeaconRegion(uuid: uuid, identifier: identifier)
}
beaconRegion.notifyEntryStateOnDisplay = true
beaconRegion.notifyOnEntry = true
beaconRegion.notifyOnExit = true
return beaconRegion
}
#endif
public func containsInRegions(_ location: CLLocation) -> Bool {
circularRegionsForMonitoring.allSatisfy { $0.containsWithAccuracy(location) }
}
public var circularRegionsForMonitoring: [CLCircularRegion] {
if Radius >= 100 {
// zone is big enough to not have false-enters
let region = CLCircularRegion(center: center, radius: Radius, identifier: identifier)
region.notifyOnEntry = true
region.notifyOnExit = true
return [region]
} else {
// zone is too small for region monitoring without false-enters
// see https://github.com/home-assistant/iOS/issues/784
// given we're a circle centered at (lat, long) with radius R
// and we want to be a series of circles with radius 100m that overlap our circle as best as possible
let numberOfCircles = 3
let minimumRadius: Double = 100.0
let centerOffset = Measurement<UnitLength>(value: minimumRadius - Radius, unit: .meters)
let sliceAngle = ((2.0 * Double.pi) / Double(numberOfCircles))
let angles: [Measurement<UnitAngle>] = (0 ..< numberOfCircles).map { amount in
.init(value: sliceAngle * Double(amount), unit: .radians)
}
return angles.map { angle in
CLCircularRegion(
center: center.moving(distance: centerOffset, direction: angle),
radius: minimumRadius,
identifier: String(format: "%@@%03.0f", identifier, angle.converted(to: .degrees).value)
)
}
}
}
override public static func primaryKey() -> String? {
#keyPath(identifier)
}
static func serverIdentifierKey() -> String {
#keyPath(serverIdentifier)
}
public var Name: String {
if isInvalidated { return "Deleted" }
if let fName = FriendlyName { return fName }
return entityId.replacingOccurrences(
of: "\(Domain).",
with: ""
).replacingOccurrences(
of: "_",
with: " "
).capitalized
}
public var deviceTrackerName: String {
entityId.replacingOccurrences(of: "\(Domain).", with: "")
}
public var Domain: String {
"zone"
}
public var isBeaconRegion: Bool {
if isInvalidated { return false }
return BeaconUUID != nil
}
override public var debugDescription: String {
"Zone - ID: \(identifier), state: " + (inRegion ? "inside" : "outside")
}
public static func zones(
of location: CLLocation,
in server: Server,
includingPassive: Bool = true
) -> [RLMZone] {
var results = Current.realm()
.objects(RLMZone.self)
.filter("%K == %@", #keyPath(serverIdentifier), server.identifier.rawValue)
.filter("TrackingEnabled == true")
if !includingPassive {
results = results.filter("isPassive == false")
}
return results
.filter { $0.circularRegion.containsWithAccuracy(location) }
.sorted { zoneA, zoneB in
// match the smaller zone over the larger
zoneA.Radius < zoneB.Radius
}
}
public static func zone(of location: CLLocation, in server: Server) -> RLMZone? {
zones(of: location, in: server).first
}
}