Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions Sources/Shared/API/HAAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ public class HomeAssistantAPI {
) -> Promise<Void> {
let update: WebhookUpdateLocation
let location: CLLocation?
let supportsInZones = server.info.version >= .inZonesOnLocationUpdate
let localMetadata = WebhookResponseLocation.localMetdata(
trigger: updateType,
zone: zone
Expand All @@ -656,11 +657,18 @@ public class HomeAssistantAPI {
update = .init(trigger: updateType, location: rawLocation, zone: zone)
location = rawLocation
case .zoneOnly:
if updateType == .BeaconRegionEnter {
update = .init(trigger: updateType, usingNameOf: zone)
} else if let rawLocation {
// note this is a different zone than the event - e.g. the zone may be the one we are exiting
update = .init(trigger: updateType, usingNameOf: RLMZone.zone(of: rawLocation, in: server))
let inZones = zones(for: updateType, location: rawLocation, fallbackZone: zone)
if updateType == .BeaconRegionEnter || rawLocation != nil {
update = .init(
trigger: updateType,
usingNameOf: locationNameZone(
for: updateType,
from: inZones,
fallbackZone: zone,
supportsInZones: supportsInZones
),
inZones: supportsInZones ? inZones : nil
)
} else {
update = .init(trigger: updateType)
}
Expand Down Expand Up @@ -704,6 +712,35 @@ public class HomeAssistantAPI {
}.asVoid()
}

private func zones(
for updateType: LocationUpdateTrigger,
location rawLocation: CLLocation?,
fallbackZone zone: RLMZone?
) -> [RLMZone] {
if updateType == .BeaconRegionEnter {
return zone.flatMap { $0.TrackingEnabled ? [$0] : nil } ?? []
} else if let rawLocation {
return RLMZone.zones(of: rawLocation, in: server)
} else {
return []
}
}

private func locationNameZone(
for updateType: LocationUpdateTrigger,
from zones: [RLMZone],
fallbackZone zone: RLMZone?,
supportsInZones: Bool
) -> RLMZone? {
if supportsInZones {
return zones.first { !$0.isPassive }
} else if updateType == .BeaconRegionEnter {
return zone
} else {
return zones.first
}
}

public var sharedEventDeviceInfo: [String: String] {
[
"sourceDevicePermanentID": AppConstants.PermanentID,
Expand Down
21 changes: 17 additions & 4 deletions Sources/Shared/API/Models/RealmZone.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,16 +247,29 @@ public final class RLMZone: Object, UpdatableModel {
"Zone - ID: \(identifier), state: " + (inRegion ? "inside" : "outside")
}

public static func zone(of location: CLLocation, in server: Server) -> Self? {
Current.realm()
.objects(Self.self)
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
}
.first
}

public static func zone(of location: CLLocation, in server: Server) -> RLMZone? {
zones(of: location, in: server).first
}
}
8 changes: 6 additions & 2 deletions Sources/Shared/API/Models/WebhookUpdateLocation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public struct WebhookUpdateLocation: ImmutableMappable {
public var battery: Int?
public var location: CLLocationCoordinate2D?
public var locationName: String?
public var inZones: [String]?

public var speed: CLLocationSpeed?
public var altitude: CLLocationDistance?
Expand All @@ -29,13 +30,15 @@ public struct WebhookUpdateLocation: ImmutableMappable {
}
}

public init(trigger: LocationUpdateTrigger, usingNameOf zone: RLMZone?) {
public init(trigger: LocationUpdateTrigger, usingNameOf zone: RLMZone?, inZones: [RLMZone]? = nil) {
self.init(trigger: trigger)
self.locationName = zone?.deviceTrackerName ?? LocationNames.NotHome.rawValue
self.inZones = inZones?.map(\.entityId)
}

public init(trigger: LocationUpdateTrigger, location: CLLocation?, zone: RLMZone?) {
public init(trigger: LocationUpdateTrigger, location: CLLocation?, zone: RLMZone?, inZones: [RLMZone]? = nil) {
self.init(trigger: trigger)
self.inZones = inZones?.map(\.entityId)

let useLocation: Bool

Expand Down Expand Up @@ -111,6 +114,7 @@ public struct WebhookUpdateLocation: ImmutableMappable {
location >>> (map["gps"], CLLocationCoordinate2DTransform())
horizontalAccuracy >>> map["gps_accuracy"]
locationName >>> map["location_name"]
inZones >>> map["in_zones"]
speed >>> map["speed"]
altitude >>> map["altitude"]
course >>> map["course"]
Expand Down
2 changes: 2 additions & 0 deletions Sources/Shared/Environment/AppConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ public extension Version {
static let canNavigateMoreInfoDialogThroughFrontend: Version = .init(major: 2026, minor: 1, prerelease: "any0")
/// Frontend introduces the quickbar with Ctrl+K keyboard shortcut in 2026.2
static let quickSearchKeyboardShortcut: Version = .init(major: 2026, minor: 2, prerelease: "any0")
/// Core accepts `in_zones` in update_location payloads from 2026.6.0.
static let inZonesOnLocationUpdate: Version = .init(major: 2026, minor: 6, patch: 0, prerelease: "any0")

var coreRequiredString: String {
L10n.requiresVersion(String(format: "core-%d.%d", major, minor ?? -1))
Expand Down
17 changes: 17 additions & 0 deletions Tests/Shared/RealmZone.test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@ class RealmZoneTests: XCTestCase {
in: server1
)
XCTAssertEqual(inside1?.entityId, "zone1_b", "should prefer smaller")
XCTAssertEqual(
RLMZone.zones(
of: CLLocation(latitude: 37.77427675230296, longitude: -122.39145063179514),
in: server1
).map(\.entityId),
["zone1_b", "zone1_a"],
"should return all matching zones, sorted by radius"
)

let inside2 = RLMZone.zone(
of: CLLocation(latitude: 37.76392336744542, longitude: -122.41274993932525),
Expand All @@ -187,6 +195,15 @@ class RealmZoneTests: XCTestCase {
in: server1
)
XCTAssertEqual(insidePassive?.entityId, "zone_passive", "passive zone with TrackingEnabled should be returned")
XCTAssertEqual(
RLMZone.zones(
of: CLLocation(latitude: 37.80535, longitude: -122.43194),
in: server1,
includingPassive: false
).map(\.entityId),
[],
"passive zones should be excluded when requested"
)

let insideDisabled = RLMZone.zone(
of: CLLocation(latitude: 37.80290, longitude: -122.45290),
Expand Down
26 changes: 26 additions & 0 deletions Tests/Shared/Webhook/WebhookUpdateLocation.test.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,31 @@ class WebhookUpdateLocationTests: XCTestCase {
XCTAssertNil(json["vertical_accuracy"])
}

func testNameOfZoneWithInZones() {
Current.device.batteries = { [DeviceBattery(level: 44, state: .charging, attributes: [:])] }

let zone = with(RLMZone()) {
$0.entityId = "zone.given_name"
$0.serverIdentifier = "server1"
}
let otherZone = with(RLMZone()) {
$0.entityId = "zone.other_name"
$0.serverIdentifier = "server1"
}

let model = WebhookUpdateLocation(trigger: .GPSRegionEnter, usingNameOf: zone, inZones: [zone, otherZone])
let json = model.toJSON()
XCTAssertEqual(json["battery"] as? Int, 44)
XCTAssertEqual(json["location_name"] as? String, "given_name")
XCTAssertEqual(json["in_zones"] as? [String], ["zone.given_name", "zone.other_name"])
XCTAssertNil(json["gps"])
XCTAssertNil(json["gps_accuracy"])
XCTAssertNil(json["speed"])
XCTAssertNil(json["altitude"])
XCTAssertNil(json["course"])
XCTAssertNil(json["vertical_accuracy"])
}

func testNameOfZoneWithNoZone() {
Current.device.batteries = { [DeviceBattery(level: 44, state: .charging, attributes: [:])] }

Expand Down Expand Up @@ -301,6 +326,7 @@ class WebhookUpdateLocationTests: XCTestCase {
XCTAssertEqual(json["gps"] as? [Double], [1.23, 4.56])
XCTAssertEqual(json["gps_accuracy"] as? Double, 104)
XCTAssertNil(json["location_name"])
XCTAssertNil(json["in_zones"])
XCTAssertEqual(json["speed"] as? Double, 108)
XCTAssertEqual(json["altitude"] as? Double, 103)
XCTAssertEqual(json["course"] as? Double, 106)
Expand Down
Loading