hammerspoon/extensions/location/location.lua

556 lines
29 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

--- === hs.location ===
---
--- Determine the machine's location and useful information about that location
---
--- This module provides functions for getting current location information and tracking location changes. It expands on the earlier version of the module by adding the ability to create independant locationObjects which can enable/disable location tracking independant of other uses of Location Services by Hammerspoon, adds region monitoring for exit and entry, and adds the retrieval of geocoding information through the `hs.location.geocoder` submodule.
---
--- This module is backwards compatible with its predecessor with the following changes:
--- * [hs.location.get](#get) - no longer requires that you invoke [hs.location.start](#start) before using this function. The information returned will be the last cached value, which is updated internally whenever additional WiFi networks are detected or lost (not necessarily joined). When update tracking is enabled with the [hs.location.start](#start) function, calculations based upon the RSSI of all currently seen networks are preformed more often to provide a more precise fix, but it's still based on the WiFi networks near you. In many cases, the value retrieved when the WiFi state is changed should be sufficiently accurate.
--- * [hs.location.servicesEnabled](#servicesEnabled) - replaces `hs.location.services_enabled`. While the earlier function is included for backwards compatibility, it will display a deprecation warning to the console the first time it is invoked and may go away completely in the future.
---
--- The following labels are used to describe tables which are used by functions and methods as parameters or return values in this module and in `hs.location.geocoder`. These tables are described as follows:
---
--- * `locationTable` - a table specifying location coordinates containing one or more of the following key-value pairs:
--- * `latitude` - a number specifying the latitude in degrees. Positive values indicate latitudes north of the equator. Negative values indicate latitudes south of the equator. When not specified in a table being used as an argument, this defaults to 0.0.
--- * `longitude` - a number specifying the longitude in degrees. Measurements are relative to the zero meridian, with positive values extending east of the meridian and negative values extending west of the meridian. When not specified in a table being used as an argument, this defaults to 0.0.
--- * `altitude` - a number indicating altitude above (positive) or below (negative) sea-level. When not specified in a table being used as an argument, this defaults to 0.0.
--- * `horizontalAccuracy` - a number specifying the radius of uncertainty for the location, measured in meters. If negative, the `latitude` and `longitude` keys are invalid and should not be trusted. When not specified in a table being used as an argument, this defaults to 0.0.
--- * `verticalAccuracy` - a number specifying the accuracy of the altitude value in meters. If negative, the `altitude` key is invalid and should not be trusted. When not specified in a table being used as an argument, this defaults to -1.0.
--- * `course` - a number specifying the direction in which the device is traveling. If this value is negative, then the value is invalid and should not be trusted. On current Macintosh models, this will almost always be a negative number. When not specified in a table being used as an argument, this defaults to -1.0.
--- * `speed` - a number specifying the instantaneous speed of the device in meters per second. If this value is negative, then the value is invalid and should not be trusted. On current Macintosh models, this will almost always be a negative number. When not specified in a table being used as an argument, this defaults to -1.0.
--- * `timestamp` - a number specifying the time at which this location was determined. This number is the number of seconds since January 1, 1970 at midnight, GMT, and is a floating point number, so you should use `math.floor` on this number before using it as an argument to Lua's `os.date` function. When not specified in a table being used as an argument, this defaults to the current time.
---
--- * `regionTable` - a table specifying a circular region containing one or more of the following key-value pairs:
--- * `identifier` - a string for use in identifying the region. When not specified in a table being used as an argument, a new value is generated with `hs.host.uuid`.
--- * `latitude` - a number specifying the latitude in degrees. Positive values indicate latitudes north of the equator. Negative values indicate latitudes south of the equator. When not specified in a table being used as an argument, this defaults to 0.0.
--- * `longitude` - a number specifying the latitude in degrees. Positive values indicate latitudes north of the equator. Negative values indicate latitudes south of the equator. When not specified in a table being used as an argument, this defaults to 0.0.
--- * `radius` - a number specifying the radius (measured in meters) that defines the regions outer boundary. When not specified in a table being used as an argument, this defaults to 0.0.
--- * `notifyOnEntry` - a boolean specifying whether or not a callback with the "didEnterRegion" message should be generated when the machine enters the region. When not specified in a table being used as an argument, this defaults to true.
--- * `notifyOnExit` - a boolean specifying whether or not a callback with the "didExitRegion" message should be generated when the machine exits the region. When not specified in a table being used as an argument, this defaults to true.
local USERDATA_TAG = "hs.location"
--local GEOCODE_UD_TAG = USERDATA_TAG .. ".geocode"
local module = require("hs.liblocation")
local host = require("hs.host")
local objectMT = {
__name = USERDATA_TAG,
__type = USERDATA_TAG,
}
local objectInternals = setmetatable({}, { __mode = "kv" })
-- private variables and methods -----------------------------------------
-- we only really need to start once, but we need to track who has started us
local startedFor = {}
local locationStart = module.start
local locationStop = module.stop
-- Set up callback dispatcher
local legacyCallbacks = {}
local __dispatch = function(msg, ...)
-- print("in callback for " .. msg)
if msg == "didUpdateLocations" then
for id, _ in pairs(startedFor) do
if id == "legacy" then
-- handle legacy callbacks
local locationNow = module.get()
for _, callback in pairs(legacyCallbacks) do
if not(callback.distance and module.distance(locationNow, callback.last) < callback.distance) then
callback.last = {
latitude = locationNow.latitude,
longitude = locationNow.longitude,
timestamp = locationNow.timestamp
}
callback.fn(locationNow)
end
end
else
local _self = objectInternals[id]
if _self and _self._callbk then
_self._callbk(_self, msg, ...)
end
end
end
else
-- handle object callbacks for other messages
for k, v in pairs(objectInternals) do
if type(k) == "string" then
local id, _self = k, v
if _self._callbk then
if msg:match("Region$") then
local args = table.pack(...)
local originalID = args[1].identifier
local regionID = originalID:match("^([%w-]+)%%%%")
-- print("region id passed:" .. originalID .. " --> " .. regionID .. " wanted:" .. id)
if regionID == id then
-- CLLocationManagerDelegate methods returns a copy of the region data being
-- monitored and this copy does not reliably contain the proper values for the
-- notifyOnEntry and notifyOnExit keys... it must elsewhere, because the proper
-- notifications *do* occur, but what we get back has random values for these
-- fields, so we copy them at creation time and "reset" them here
args[1].notifyOnEntry = _self.regions[originalID].notifyOnEntry
args[1].notifyOnExit = _self.regions[originalID].notifyOnExit
-- return the objects name for the region, not the internal one
args[1].identifier = _self.regions[originalID].identifier
_self._callbk(_self, msg, table.unpack(args))
end
else
_self._callbk(_self, msg, ...)
end
end
end
end
end
end
local registerCallback = module._registerCallback
module._registerCallback = nil -- not needed again until/unless Hammerspoon restarted
registerCallback(__dispatch)
-- note, will choke on recursion and ignores metatables
local simpleCopy
simpleCopy = function(t1)
if type(t1) == "table" then
local t2 = {}
for k, v in pairs(t1) do t2[k] = simpleCopy(v) end
return t2
else
return t1
end
end
-- Public interface ------------------------------------------------------
-- legacy functions
local hasSeenDeprecationWarning = false
module.services_enabled = function(...)
if not hasSeenDeprecationWarning then
print("** hs.location.services_enabled is deprecated; use hs.location.servicesEnabled instead")
hasSeenDeprecationWarning = true
end
return module.servicesEnabled(...)
end
--- hs.location.register(tag, fn[, distance])
--- Function
--- Registers a callback function to be called when the system location is updated
---
--- Parameters:
--- * `tag` - A string containing a unique tag, used to identify the callback later
--- * `fn` - A function to be called when the system location is updated. The function should expect a single argument which will be a locationTable as described in the module header.
--- * `distance` - An optional number containing the minimum distance in meters that the system should have moved, before calling the callback. Defaults to 0
---
--- Returns:
--- * None
module.register = function(tag, fn, distance)
if legacyCallbacks[tag] then
error("Callback tag '"..tag.."' already registered for hs.location.", 2)
else
legacyCallbacks[tag] = {
fn = fn,
distance = distance,
last = {
latitude = 0,
longitude = 0,
timestamp = 0,
}
}
end
end
--- hs.location.unregister(tag)
--- Function
--- Unregisters a callback
---
--- Parameters:
--- * `tag` - A string containing the unique tag a callback was registered with
---
--- Returns:
--- * None
module.unregister = function(tag)
legacyCallbacks[tag] = nil
end
--- hs.location.start() -> boolean
--- Function
--- Begins location tracking using OS X's Location Services so that registered callback functions can be invoked as the computer location changes.
---
--- Parameters:
--- * None
---
--- Returns:
--- * True if the operation succeeded, otherwise false
---
--- Notes:
--- * This function activates Location Services for Hammerspoon, so the first time you call this, you may be prompted to authorise Hammerspoon to use Location Services.
module.start = function()
local result = true
-- if startedFor is empty, then start
if not next(startedFor) then result = locationStart() end
-- if result is true (it will only be false if we tried to start and failed), update for legacy
if result then startedFor.legacy = true end
-- force a nil to be false to match documented behavior
return startedFor.legacy or false
end
--- hs.location.stop()
--- Function
--- Stops location tracking. Registered callback functions will cease to receive notification of location changes.
---
--- Parameters:
--- * None
---
--- Returns:
--- * None
module.stop = function()
-- if startedFor is not empty, then clear for legacy
if next(startedFor) then
startedFor.legacy = nil
-- now, if startedFor *is* empty, then actually stop
if not next(startedFor) then locationStop() end
end
end
-- hs.location independant objects
--- hs.location.new() -> locationObject
--- Constructor
--- Create a new location object which can receive callbacks independant of other Hammerspoon use of Location Services.
---
--- Parameters:
--- * None
---
--- Returns:
--- * a locationObject
---
--- Notes:
--- * The locationObject created will receive callbacks independant of all other locationObjects and the legacy callback functions created with [hs.location.register](#register). It can also receive callbacks for region changes which are not available through the legacy callback mechanism.
module.new = function()
local self = { id = host.uuid(), regions = {} }
self.label = tostring(self):match("^table: (.+)$")
objectInternals[self] = self.id
objectInternals[self.id] = self
return setmetatable(self, objectMT)
end
objectMT.__index = objectMT
objectMT.__eq = function(self, other)
return self.id == other.id
end
objectMT.__gc = function(self)
self._callbk = nil
self:stopTracking()
for _,v in ipairs(self:monitoredRegions()) do self:removeMonitoredRegion(v.identifier) end
-- yeah, internal gc will get these, but it takes two passes to get both, so lets just kill both at once
objectInternals[self.id] = nil
objectInternals[self] = nil
setmetatable(self, nil)
end
objectMT.__tostring = function(self)
-- I'm a traditionalist... it really shouldn't matter to the user that we're using a
-- table rather than a true userdata, so the tostring method should act similarly
return string.format("%s: (%s)", USERDATA_TAG, self.label)
end
--- hs.location:startTracking() -> locationObject
--- Method
--- Enable callbacks for location changes/refinements for this locationObject
---
--- Parameters:
--- * None
---
--- Returns:
--- * the locationObject
---
--- Notes:
--- * This function activates Location Services for Hammerspoon, so the first time you call this, you may be prompted to authorise Hammerspoon to use Location Services.
objectMT.startTracking = function(self)
local result = true
-- if startedFor is empty, then start
if not next(startedFor) then result = locationStart() end
-- if result is true (it will only be false if we tried to start and failed), update for id
if result then startedFor[self.id] = true end
return self
end
--- hs.location:stopTracking() -> locationObject
--- Method
--- Disable callbacks for location changes/refinements for this locationObject
---
--- Parameters:
--- * None
---
--- Returns:
--- * the locationObject
objectMT.stopTracking = function(self)
-- if startedFor is not empty, then clear for id
if next(startedFor) then
startedFor[self.id] = nil
-- now, if startedFor *is* empty, then actually stop
if not next(startedFor) then locationStop() end
end
return self
end
--- hs.location:distanceFrom(locationTable) -> distance | nil
--- Method
--- Enable callbacks for location changes/refinements for this locationObject
---
--- Parameters:
--- * None
---
--- Returns:
--- * the distance the specified location is from the current location in meters or nil if Location Services cannot be enabled for Hammerspoon. The measurement is made by tracing a line that follows an idealised curvature of the earth
---
--- Notes:
--- * This function activates Location Services for Hammerspoon, so the first time you call this, you may be prompted to authorise Hammerspoon to use Location Services.
objectMT.distanceFrom = function(self, ...)
local current = self:get()
if current then
return module.distance(self:get(), ...)
else
return nil
end
end
--- hs.location:monitoredRegions() -> table | nil
--- Method
--- Returns a table containing the regionTables for the regions currently being monitored for this locationObject
---
--- Parameters:
--- * None
---
--- Returns:
--- * if Location Services can be enabled for Hammerspoon, returns a table containing regionTables for each region which is being monitored for this locationObject; otherwise nil
---
--- Notes:
--- * This method activates Location Services for Hammerspoon, so the first time you call this, you may be prompted to authorise Hammerspoon to use Location Services.
local monitoredRegions = module._monitoredRegions
objectMT.monitoredRegions = function(self)
local regions = monitoredRegions()
if regions then
local results = {}
for _, v in ipairs(regions) do
if self.regions[v.identifier] then
table.insert(results, v)
-- [CLLocationManager monitoredRegions] returns a copy of the region data being
-- monitored and this copy does not reliably contain the proper values for the
-- notifyOnEntry and notifyOnExit keys... it must elsewhere, because the proper
-- notifications *do* occur, but what we get back has random values for these
-- fields, so we copy them at creation time and "reset" them here
results[#results].notifyOnEntry = self.regions[v.identifier].notifyOnEntry
results[#results].notifyOnExit = self.regions[v.identifier].notifyOnExit
-- return the objects name for the region, not the internal one
results[#results].identifier = self.regions[v.identifier].identifier
end
end
return results
else
return nil
end
end
--- hs.location:addMonitoredRegion(regionTable) -> locationObject | nil
--- Method
--- Adds a region to be monitored by Location Services
---
--- Parameters:
--- * `regionTable` - a region table as described in the module header
---
--- Returns:
--- * if the region table was able to be added to Location Services for monitoring, returns the locationObject; otherwise returns nil
---
--- Notes:
--- * This method activates Location Services for Hammerspoon, so the first time you call this, you may be prompted to authorise Hammerspoon to use Location Services.
--- * If the `identifier` key is not provided, a new UUID string is generated and used as the identifier.
--- * If the `identifier` key matches an already monitored region, this region will replace the existing one.
local addMonitoredRegion = module._addMonitoredRegion
objectMT.addMonitoredRegion = function(self, newRegion)
local regionDetails, newIdentifier = {}, self.id
if type(newRegion) == "table" then
-- if newRegion.identifier:match("%%%%") then
-- error("region identifier cannot contain internal delimiter '%%'", 2)
-- end
-- in case they are keeping the newRegion table somewhere else, we don't want to modify it
newRegion = simpleCopy(newRegion)
-- now capture what we need for tracking and reference by other region aware code
regionDetails.identifier = newRegion.identifier or host.uuid()
if type(newRegion.notifyOnEntry) == "boolean" then
regionDetails.notifyOnEntry = newRegion.notifyOnEntry
else
regionDetails.notifyOnEntry = true
end
if type(newRegion.notifyOnExit) == "boolean" then
regionDetails.notifyOnExit = newRegion.notifyOnExit
else
regionDetails.notifyOnExit = true
end
newIdentifier = newIdentifier .. "%%" .. regionDetails.identifier
newRegion.identifier = newIdentifier
end
-- else if not a table, the next line will error out for us
local wasAdded = addMonitoredRegion(newRegion)
if wasAdded then
self.regions[newIdentifier] = regionDetails
return self
else
return wasAdded
end
end
--- hs.location:removeMonitoredRegion(identifier) -> locationObject | false | nil
--- Method
--- Removes a monitored region from Location Services
---
--- Parameters:
--- * `identifier` - a string which should contain the identifier of the region to remove from monitoring
---
--- Returns:
--- * if the region identifier matches a currently monitored region, returns the locationObject; if it does not match a currently monitored region, returns false; returns nil if an error occurs or if Location Services is not currently active (no function or method which activates Location Services has been invoked yet) or enabled for Hammerspoon.
---
--- Notes:
--- * This method activates Location Services for Hammerspoon, so the first time you call this, you may be prompted to authorise Hammerspoon to use Location Services.
--- * If the `identifier` key is not provided, a new UUID string is generated and used as the identifier.
--- * If the `identifier` key matches an already monitored region, this region will replace the existing one.
local removeMonitoredRegion = module._removeMonitoredRegion
objectMT.removeMonitoredRegion = function(self, identifier)
local newIdentifier = self.id .. "%%" .. identifier
local wasRemoved = removeMonitoredRegion(newIdentifier)
if wasRemoved then
self.regions[newIdentifier] = nil
return self
else
return wasRemoved
end
end
module._monitoredRegions = nil -- not supported in legacy mode, so hide from use
module._addMonitoredRegion = nil -- not supported in legacy mode, so hide from use
module._removeMonitoredRegion = nil -- not supported in legacy mode, so hide from
--- hs.location:currentRegion() -> identifier | nil
--- Method
--- Returns the string identifier for the current region
---
--- Parameters:
--- * None
---
--- Returns:
--- * the string identifier for the region that the current location is within, or nil if the current location is not within a currently monitored region or location services cannot be enabled for Hammerspoon.
---
--- Notes:
--- * This method activates Location Services for Hammerspoon, so the first time you call this, you may be prompted to authorise Hammerspoon to use Location Services.
objectMT.currentRegion = function(self)
local location, regions = self:location(), self:monitoredRegions()
local currentRegion, currentRadius = nil, math.huge
if location then
for _,v in ipairs(regions) do
if module.distance(location, v) < v.radius and v.radius < currentRadius then
currentRadius, currentRegion = v.radius, v.identifier
end
end
end
return currentRegion
end
--- hs.location:callback(fn) -> locationObject
--- Method
--- Sets or removes the callback function for this locationObject
---
--- Parameters:
--- * a function, or nil to remove the current function, which will be invoked as a callback for messages generated by this locationObject. The callback function should expect 3 or 4 arguments as follows:
--- * the locationObject itself
--- * a string specifying the message generated by the locationObject:
--- * "didChangeAuthorizationStatus" - the user has changed the authorization status for Hammerspoon's use of Location Services. The third argument will be a string as described in the [hs.location.authorizationStatus](#authorizationStatus) function.
--- * "didUpdateLocations" - the current location has changed or been refined. This message will only occur if location tracking has been enabled with [hs.location:startTracking](#startTracking). The third argument will be a table containing one or more locationTables as array elements. The most recent location update is contained in the last element of the array.
--- * "didFailWithError" - there was an error retrieving location information. The third argument will be a string describing the error that occurred.
--- * "didStartMonitoringForRegion" - a new region has successfully been added to the regions being monitored. The third argument will be the regionTable for the region which was just added.
--- * "monitoringDidFailForRegion" - an error occurred while trying to add a new region to the list of monitored regions. The third argument will be the regionTable for the region that could not be added, and the fourth argument will be a string containing an error message describing why monitoring for the region failed.
--- * "didEnterRegion" - the current location has entered a region with the `notifyOnEntry` field set to true specified with the [hs.location:addMonitoredRegion](#addMonitoredRegion) method. The third argument will be the regionTable for the region entered.
--- * "didExitRegion" - the current location has exited a region with the `notifyOnExit` field set to true specified with the [hs.location:addMonitoredRegion](#addMonitoredRegion) method. The third argument will be the regionTable for the region exited.
---
--- Returns:
--- * the locationObject
objectMT.callback = function(self, ...)
-- sigh, the only way to check for an explicit nil
local args = table.pack(...)
if args.n == 1 then
local fn = args[1]
if type(fn) == "function" or type(fn) == "nil" then
self._callbk = fn
else
error("expeected a function or nil, found " .. type(fn), 2)
end
else
error("expected 1 argument, found " .. tostring(args.n), 2)
end
return self
end
--- hs.location:location() -> locationTable | nil
--- Method
--- Returns the current location
---
--- Parameters:
--- * None
---
--- Returns:
--- * If successful, a locationTable as described in the module header, otherwise nil.
---
--- Notes:
--- * This function activates Location Services for Hammerspoon, so the first time you call this, you may be prompted to authorise Hammerspoon to use Location Services.
--- * If access to Location Services is enabled for Hammerspoon, this function will return the most recent cached data for the computer's location.
--- * Internally, the Location Services cache is updated whenever additional WiFi networks are detected or lost (not necessarily joined). When update tracking is enabled with the [hs.location.start](#start) function, calculations based upon the RSSI of all currently seen networks are preformed more often to provide a more precise fix, but it's still based on the WiFi networks near you.
objectMT.location = function()
return module.get()
end
-- Return Module Object --------------------------------------------------
-- assign to the registry in case we ever need to access the metatable from the C side
debug.getregistry()[USERDATA_TAG] = objectMT
-- the following allows access to internal state and methods/functions which are otherwise hidden
-- because the average user shouldn't need them, but they may prove useful when debugging; this
-- allows us to access them without advertising them either through the docs or hs.inspect
local internal = {}
internal._fakeLocationChange, module._fakeLocationChange = module._fakeLocationChange, nil
internal.__legacyCallbacks = legacyCallbacks -- legacy callback tags -> { fn, distance, last }
internal.__startedFor = startedFor -- which trackers have been started
internal.__objectInternals = objectInternals -- internals for "object" version
internal._monitoredRegions = monitoredRegions -- actual monitoredRegions function
internal._addMonitoredRegion = addMonitoredRegion -- actual addMonitoredRegion function
internal._removeMonitoredRegion = removeMonitoredRegion -- actual removeMonitoredRegion function
internal._registerCallback = registerCallback -- actual registerCallback function
internal._start = locationStart -- actual module start function without wrapper
internal._stop = locationStop -- actual module stop function without wrapper
internal._debugHelp = function()
local results = "Debugging keys for " .. USERDATA_TAG .. " are:\n"
local size = 0
for k, _ in pairs(internal) do if #k > size then size = #k end end
for k, v in require("hs.fnutils").sortByKeys(internal) do
results = results .. string.format(" %-" .. tostring(size) .. "s %s\n", k, tostring(v))
end
return results
end
-- now add metamethod that allows actually using these hidden items
local meta = getmetatable(module)
meta.__index = function(_, key) return internal[key] end
-- protect our debugging keys, but allow users to inject other stuff if they wish
meta.__newindex = function(_, key, value)
if internal[key] then
error(tostring(key) .. " is a protected key in " .. USERDATA_TAG, 2)
else
module[key] = value
end
end
setmetatable(module, meta)
return module