-
Notifications
You must be signed in to change notification settings - Fork 277
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
ZoneManager: Rewrite & Test Region Monitoring (#698)
- Rewrites the region monitoring into several smaller, more testable pieces. - Does a one-shot location update whenever a region's state changes. - Writes a whole lot of tests around it. - Still wraps it in the "In-Development Updating" setting flag so I can get beta feedback and iterate. Replaces #697.
- Loading branch information
Showing
23 changed files
with
1,552 additions
and
177 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import Foundation | ||
import PromiseKit | ||
import CoreLocation | ||
import Shared | ||
import UIKit | ||
|
||
class ZoneManager { | ||
let locationManager: CLLocationManager | ||
let collector: ZoneManagerCollector | ||
let processor: ZoneManagerProcessor | ||
|
||
init( | ||
locationManager: CLLocationManager = .init(), | ||
collector: ZoneManagerCollector = ZoneManagerCollectorImpl(), | ||
processor: ZoneManagerProcessor = ZoneManagerProcessorImpl() | ||
) { | ||
self.collector = collector | ||
self.processor = processor | ||
|
||
self.locationManager = with(locationManager) { | ||
$0.allowsBackgroundLocationUpdates = true | ||
$0.pausesLocationUpdatesAutomatically = false | ||
} | ||
|
||
// we separate out the delegate from ourselves to force a clean separation from location manager | ||
self.collector.delegate = self | ||
self.processor.delegate = self | ||
|
||
log(state: .initialize) | ||
syncZones().cauterize() | ||
locationManager.delegate = collector | ||
locationManager.startMonitoringSignificantLocationChanges() | ||
} | ||
|
||
private func log(state: ZoneManagerState) { | ||
Current.Log.info(state) | ||
} | ||
|
||
private func perform(event: ZoneManagerEvent) { | ||
let logPayload: [String: Any] = [ | ||
"start_ssid": Current.connectivity.currentWiFiSSID() ?? "none", | ||
"event": event.description | ||
] | ||
|
||
// although technically the processor also does this, it does it after some async processing. | ||
// let's be very cofident that we're not going to miss out on an update due to being suspended | ||
UIApplication.shared.backgroundTask(withName: "zone-manager-perform-event") { _ in | ||
processor.perform(event: event) | ||
}.done { | ||
Current.clientEventStore.addEvent(ClientEvent( | ||
text: "Updated location", | ||
type: .locationUpdate, | ||
payload: logPayload | ||
)) | ||
}.catch { error in | ||
Current.Log.error("final error for \(event): \(error)") | ||
Current.clientEventStore.addEvent(ClientEvent( | ||
text: "Failed updating: \(error.localizedDescription)", | ||
type: .locationUpdate, | ||
payload: logPayload | ||
)) | ||
} | ||
} | ||
|
||
internal func syncZones() -> Promise<Void> { | ||
let expected = Set( | ||
Current.realm() | ||
.objects(RLMZone.self) | ||
.map { $0.region() } | ||
.map(ZoneManagerEquatableRegion.init(region:)) | ||
) | ||
let actual = Set( | ||
locationManager | ||
.monitoredRegions | ||
.map(ZoneManagerEquatableRegion.init(region:)) | ||
) | ||
|
||
let needsRemoval = actual.subtracting(expected) | ||
let needsAddition = expected.subtracting(actual) | ||
|
||
// process removals before additions | ||
// this is important because the system is focused on identifier | ||
for region in needsRemoval.map(\.region) { | ||
Current.clientEventStore.addEvent(ClientEvent( | ||
text: "Ending monitoring \(region)", | ||
type: .locationUpdate | ||
)) | ||
locationManager.stopMonitoring(for: region) | ||
} | ||
|
||
for region in needsAddition.map(\.region) { | ||
Current.clientEventStore.addEvent(ClientEvent( | ||
text: "Initially monitoring \(region)", | ||
type: .locationUpdate | ||
)) | ||
locationManager.startMonitoring(for: region) | ||
} | ||
|
||
Current.Log.info { | ||
[ | ||
"monitoring \(expected.count)", | ||
"started \(needsAddition.count)", | ||
"ended \(needsRemoval.count)" | ||
].joined(separator: ", ") | ||
} | ||
return .value(()) | ||
} | ||
} | ||
|
||
extension ZoneManager: ZoneManagerCollectorDelegate { | ||
func collector(_ collector: ZoneManagerCollector, didLog state: ZoneManagerState) { | ||
log(state: state) | ||
} | ||
|
||
func collector(_ collector: ZoneManagerCollector, didCollect event: ZoneManagerEvent) { | ||
perform(event: event) | ||
} | ||
} | ||
|
||
extension ZoneManager: ZoneManagerProcessorDelegate { | ||
func processor(_ processor: ZoneManagerProcessor, didLog state: ZoneManagerState) { | ||
log(state: state) | ||
} | ||
} |
67 changes: 67 additions & 0 deletions
67
HomeAssistant/Classes/ZoneManager/ZoneManagerCollector.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import CoreLocation | ||
import PromiseKit | ||
import Shared | ||
|
||
protocol ZoneManagerCollectorDelegate: AnyObject { | ||
func collector(_ collector: ZoneManagerCollector, didLog state: ZoneManagerState) | ||
func collector(_ collector: ZoneManagerCollector, didCollect event: ZoneManagerEvent) | ||
} | ||
|
||
protocol ZoneManagerCollector: CLLocationManagerDelegate { | ||
var delegate: ZoneManagerCollectorDelegate? { get set } | ||
} | ||
|
||
class ZoneManagerCollectorImpl: NSObject, ZoneManagerCollector { | ||
weak var delegate: ZoneManagerCollectorDelegate? | ||
|
||
func locationManager( | ||
_ manager: CLLocationManager, | ||
didFailWithError error: Error | ||
) { | ||
delegate?.collector(self, didLog: .didError(error)) | ||
} | ||
|
||
func locationManager( | ||
_ manager: CLLocationManager, | ||
monitoringDidFailFor region: CLRegion?, | ||
withError error: Error | ||
) { | ||
delegate?.collector(self, didLog: .didFailMonitoring(region, error)) | ||
} | ||
|
||
func locationManager( | ||
_ manager: CLLocationManager, | ||
didStartMonitoringFor region: CLRegion | ||
) { | ||
delegate?.collector(self, didLog: .didStartMonitoring(region)) | ||
manager.requestState(for: region) | ||
} | ||
|
||
func locationManager( | ||
_ manager: CLLocationManager, | ||
didDetermineState state: CLRegionState, | ||
for region: CLRegion | ||
) { | ||
let zone = Current.realm() | ||
.objects(RLMZone.self) | ||
.first(where: { $0.ID == region.identifier }) | ||
|
||
let event = ZoneManagerEvent( | ||
eventType: .region(region, state), | ||
associatedZone: zone | ||
) | ||
|
||
delegate?.collector(self, didCollect: event) | ||
} | ||
|
||
func locationManager( | ||
_ manager: CLLocationManager, | ||
didUpdateLocations locations: [CLLocation] | ||
) { | ||
let event = ZoneManagerEvent( | ||
eventType: .locationChange(locations) | ||
) | ||
|
||
delegate?.collector(self, didCollect: event) | ||
} | ||
} |
38 changes: 38 additions & 0 deletions
38
HomeAssistant/Classes/ZoneManager/ZoneManagerEquatableRegion.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import Foundation | ||
import CoreLocation | ||
|
||
/// Wraps CLRegion but does deep inspection of properties | ||
/// so changing e.g. lat/long breaks Equatable rather than relying on identifier | ||
struct ZoneManagerEquatableRegion: Hashable { | ||
let region: CLRegion | ||
|
||
private var beaconRegion: CLBeaconRegion? { | ||
region as? CLBeaconRegion | ||
} | ||
|
||
private var circularReason: CLCircularRegion? { | ||
region as? CLCircularRegion | ||
} | ||
|
||
func hash(into hasher: inout Hasher) { | ||
hasher.combine(region.hash) | ||
} | ||
|
||
static func == (lhs: ZoneManagerEquatableRegion, rhs: ZoneManagerEquatableRegion) -> Bool { | ||
guard lhs.region.identifier == rhs.region.identifier else { | ||
return false | ||
} | ||
|
||
if let lhs = lhs.beaconRegion, let rhs = rhs.beaconRegion { | ||
return lhs.proximityUUID == rhs.proximityUUID && | ||
lhs.minor == rhs.minor && | ||
lhs.major == rhs.major | ||
} else if let lhs = lhs.circularReason, let rhs = rhs.circularReason { | ||
return lhs.center.latitude == rhs.center.latitude && | ||
lhs.center.longitude == rhs.center.longitude && | ||
lhs.radius == rhs.radius | ||
} else { | ||
return false | ||
} | ||
} | ||
} |
Oops, something went wrong.