263 lines
8.7 KiB
Swift
263 lines
8.7 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).cauterize()
|
|
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 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
|
|
}
|
|
}
|