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() public let BeaconMinor = RealmProperty() // SSID public var SSIDTrigger = List() public var SSIDFilter = List() 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(value: minimumRadius - Radius, unit: .meters) let sliceAngle = ((2.0 * Double.pi) / Double(numberOfCircles)) let angles: [Measurement] = (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 zone(of location: CLLocation, in server: Server) -> Self? { Current.realm() .objects(Self.self) .filter("%K == %@", #keyPath(serverIdentifier), server.identifier.rawValue) .filter(trackablePredicate) .filter { $0.circularRegion.containsWithAccuracy(location) } .sorted { zoneA, zoneB in // match the smaller zone over the larger zoneA.Radius < zoneB.Radius } .first } }