From cfd656dd0ef788b14da86362c1678c14cb89b06a Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Tue, 6 Oct 2020 09:55:02 +1100 Subject: [PATCH 1/5] feat(place area count): initial work --- drivers/place/area_count.cr | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 drivers/place/area_count.cr diff --git a/drivers/place/area_count.cr b/drivers/place/area_count.cr new file mode 100644 index 00000000000..c68da09a52c --- /dev/null +++ b/drivers/place/area_count.cr @@ -0,0 +1,35 @@ +module Place; end + +require "pinger" + +class Place::AreaCount < PlaceOS::Driver + descriptive_name "PlaceOS Area Counter" + generic_name :Counter + description %(counts trackable objects in an area, such as people) + + default_settings({ + areas: [{ + name: "lobby", + building: "zone-12345", + level: "zone-34565", + boundary: [{3, 5}, {8, 9}, {4, 6}] + }], + }) + + alias Area = NamedTuple( + name: String, + building: String?, + level: String?, + boundary: Array(Tuple(Int64, Int64)) + ) + + @boundaries : Array(Area) = [] of Area + + def on_load + on_update + end + + def on_update + @boundaries = setting(Array(Area), :areas) + end +end From 42f7aa948c0f19b89050eab403127e68d35f6d54 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Tue, 6 Oct 2020 11:38:21 +1100 Subject: [PATCH 2/5] feat(place area count): point check working --- drivers/place/area_count.cr | 41 +++++++++++++++++----- drivers/place/area_count_spec.cr | 16 +++++++++ drivers/place/area_polygon.cr | 60 ++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 drivers/place/area_count_spec.cr create mode 100644 drivers/place/area_polygon.cr diff --git a/drivers/place/area_count.cr b/drivers/place/area_count.cr index c68da09a52c..c757614480d 100644 --- a/drivers/place/area_count.cr +++ b/drivers/place/area_count.cr @@ -1,6 +1,6 @@ module Place; end -require "pinger" +require "./area_polygon" class Place::AreaCount < PlaceOS::Driver descriptive_name "PlaceOS Area Counter" @@ -9,27 +9,52 @@ class Place::AreaCount < PlaceOS::Driver default_settings({ areas: [{ - name: "lobby", + id: "lobby1", + name: "George St Lobby", building: "zone-12345", - level: "zone-34565", - boundary: [{3, 5}, {8, 9}, {4, 6}] + level: "zone-34565", + boundary: [{3, 5}, {5, 6}, {6, 1}], }], }) + alias AreaSetting = NamedTuple( + id: String, + name: String, + building: String?, + level: String?, + boundary: Array(Tuple(Float64, Float64))) + alias Area = NamedTuple( + id: String, name: String, building: String?, level: String?, - boundary: Array(Tuple(Int64, Int64)) - ) + boundary: Polygon) - @boundaries : Array(Area) = [] of Area + @boundaries : Hash(String, Area) = {} of String => Area def on_load on_update end def on_update - @boundaries = setting(Array(Area), :areas) + areas = setting(Array(AreaSetting), :areas) + + @boundaries.clear + areas.each do |area| + points = area[:boundary].map { |p| Point.new(*p) } + @boundaries[area[:id]] = { + id: area[:id], + name: area[:name], + building: area[:building], + level: area[:level], + boundary: Polygon.new(points), + } + end + end + + def is_inside?(x : Float64, y : Float64, area_id : String) + area = @boundaries[area_id] + area[:boundary].contains(x, y) end end diff --git a/drivers/place/area_count_spec.cr b/drivers/place/area_count_spec.cr new file mode 100644 index 00000000000..5b38a1457c1 --- /dev/null +++ b/drivers/place/area_count_spec.cr @@ -0,0 +1,16 @@ +DriverSpecs.mock_driver "Place::AreaCount" do + # Used this tool to work out coordinates: https://www.mathsisfun.com/geometry/polygons-interactive.html + exec(:is_inside?, 4, 5, "lobby1").get.should eq(true) + exec(:is_inside?, 4, 4, "lobby1").get.should eq(true) + exec(:is_inside?, 5, 5, "lobby1").get.should eq(true) + exec(:is_inside?, 5, 4, "lobby1").get.should eq(true) + exec(:is_inside?, 5, 3, "lobby1").get.should eq(true) + exec(:is_inside?, 3.1, 5, "lobby1").get.should eq(true) + + exec(:is_inside?, 3, 6, "lobby1").get.should eq(false) + exec(:is_inside?, 4, 6, "lobby1").get.should eq(false) + exec(:is_inside?, 4.6, 5.9, "lobby1").get.should eq(false) + exec(:is_inside?, 5.2, 5.4, "lobby1").get.should eq(false) + exec(:is_inside?, 5.5, 1.5, "lobby1").get.should eq(false) + exec(:is_inside?, 5.9, 2, "lobby1").get.should eq(false) +end diff --git a/drivers/place/area_polygon.cr b/drivers/place/area_polygon.cr new file mode 100644 index 00000000000..8bdfe7c349d --- /dev/null +++ b/drivers/place/area_polygon.cr @@ -0,0 +1,60 @@ +# Crystal lang point in a polygon, based on +# https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html + +# 1. The polygon may contain multiple separate components, and/or holes, which may be concave, provided that you separate the components and holes with a (0,0) vertex, as follows. +# First, include a (0,0) vertex. +# Then include the first component' vertices, repeating its first vertex after the last vertex. +# Include another (0,0) vertex. +# Include another component or hole, repeating its first vertex after the last vertex. +# Repeat the above two steps for each component and hole. +# Include a final (0,0) vertex. +# 2. For example, let three components' vertices be A1, A2, A3, B1, B2, B3, and C1, C2, C3. Let two holes be H1, H2, H3, and I1, I2, I3. Let O be the point (0,0). List the vertices thus: +# O, A1, A2, A3, A1, O, B1, B2, B3, B1, O, C1, C2, C3, C1, O, H1, H2, H3, H1, O, I1, I2, I3, I1, O. +# 3. Each component or hole's vertices may be listed either clockwise or counter-clockwise. +# 4. If there is only one connected component, then it is optional to repeat the first vertex at the end. It's also optional to surround the component with zero vertices. + +struct Point + def initialize(@x : Float64, @y : Float64) + end + + property x : Float64 + property y : Float64 +end + +class Polygon + def initialize(@points : Array(Point)) + @xmax = @xmin = @points[0].x + @ymax = @ymin = @points[0].y + + @points[1..-1].each do |point| + @xmax = point.x if point.x > @xmax + @ymax = point.y if point.y > @ymax + @xmin = point.x if point.x < @xmin + @ymin = point.y if point.y < @ymin + end + end + + getter points : Array(Point) + getter xmin : Float64 + getter ymin : Float64 + getter xmax : Float64 + getter ymax : Float64 + + def contains(testx : Float64, testy : Float64) + # definitely not within the polygon, quick check + return false if testx < @xmin || testx > @xmax || testy < @ymin || testy > @ymax + + inside = false + previous_index = @points.size - 1 + + @points.each_with_index do |point, index| + previous = @points[previous_index] + if ((point.y > testy) != (previous.y > testy)) && (testx < (previous.x - point.x) * (testy - point.y) / (previous.y - point.y) + point.x) + inside = !inside + end + previous_index = index + end + + inside + end +end From 3ed0640e1b96bc52ce9a37f9e06aca9d316503b5 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Fri, 9 Oct 2020 16:50:52 +1100 Subject: [PATCH 3/5] feat: current progress on area counting --- drivers/cisco/meraki/dashboard.cr | 70 +++++++++++-- drivers/cisco/meraki/dashboard_spec.cr | 13 ++- drivers/cisco/meraki/scanning_api.cr | 6 ++ drivers/place/area_config.cr | 54 ++++++++++ drivers/place/area_count.cr | 134 +++++++++++++++++++------ drivers/place/location_services.cr | 34 +++++++ 6 files changed, 274 insertions(+), 37 deletions(-) create mode 100644 drivers/place/area_config.cr diff --git a/drivers/cisco/meraki/dashboard.cr b/drivers/cisco/meraki/dashboard.cr index a7c8baa9ca2..158a2c3b61a 100644 --- a/drivers/cisco/meraki/dashboard.cr +++ b/drivers/cisco/meraki/dashboard.cr @@ -313,8 +313,8 @@ class Cisco::Meraki::Dashboard < PlaceOS::Driver user.downcase end - def macs_assigned_to(username : String) - username = format_username(username) + def macs_assigned_to(email : String? = nil, username : String? = nil) + username = format_username(username.presence || email.presence.not_nil!) if macs = user_mac_mappings[username]? Array(String).from_json(macs) else @@ -323,20 +323,29 @@ class Cisco::Meraki::Dashboard < PlaceOS::Driver end def check_ownership_of(mac_address : String) - user_mac_mappings[format_mac(mac_address)]? + if user = user_mac_mappings[format_mac(mac_address)]? + { + location: :wireless, + assigned_to: user, + mac_address: mac_address, + } + end end # returns locations based on most recently seen # versus most accurate location def locate_user(email : String? = nil, username : String? = nil) - username = format_username(username.presence || email || "") + username = format_username(username.presence || email.presence.not_nil!) if macs = user_mac_mappings[username]? location_max_age = @max_location_age.ago Array(String).from_json(macs).compact_map { |mac| if location = locate_mac(mac) - location if location.time > location_max_age + if location.time > location_max_age + location.mac = mac + location + end end }.sort { |a, b| b.time <=> a.time @@ -346,8 +355,9 @@ class Cisco::Meraki::Dashboard < PlaceOS::Driver "coordinates_from" => "bottom-left", "x" => location.x, "y" => location.y, - "lng" => location.lng, + "lon" => location.lng, "lat" => location.lat, + "mac" => location.mac, "variance" => location.variance, "last_seen" => location.time.to_unix, "meraki_floor_id" => location.floor_plan_id, @@ -372,6 +382,50 @@ class Cisco::Meraki::Dashboard < PlaceOS::Driver end end + def device_locations(zone_id : String, location : String? = nil) + return [] of Nil if location && location != "wireless" + + # Find the floors associated with the provided zone id + floors = [] of String + @floorplan_mappings.each do |floor_id, data| + floors << floor_id if data.values.includes?(zone_id) + end + return [] of Nil if floors.empty? + + # Find the devices that are on the matching floors + oldest_location = @max_location_age.ago + @locations.compact_map { |mac, location| + if location.time > oldest_location && floors.includes?(location.floor_plan_id) + location.mac = mac + location + end + }.group_by(&.floor_plan_id).flat_map { |floor_id, locations| + map_width = -1.0 + map_height = -1.0 + + if map_size = @floorplan_sizes[locations[0].floor_plan_id]? + map_width = map_size.width + map_height = map_size.height + end + + locations.map do |location| + { + location: :wireless, + coordinates_from: "bottom-left", + x: location.x, + y: location.y, + lon: location.lng, + lat: location.lat, + mac: location.mac, + variance: location.variance, + last_seen: location.time.to_unix, + map_width: map_width, + map_height: map_height, + } + end + } + end + @[Security(PlaceOS::Driver::Level::Support)] def cleanup_caches : Nil logger.debug { "removing IP and location data that is over 30 minutes old" } @@ -381,9 +435,13 @@ class Cisco::Meraki::Dashboard < PlaceOS::Driver @ip_lookup.each { |ip, lookup| remove_keys << ip if lookup.time < old } remove_keys.each { |ip| @ip_lookup.delete(ip) } + logger.debug { "removing #{remove_keys.size} IPs" } + remove_keys.clear @locations.each { |mac, location| remove_keys << mac if location.time < old } remove_keys.each { |mac| @locations.delete(mac) } + + logger.debug { "removing #{remove_keys.size} MACs" } end class FloorPlan diff --git a/drivers/cisco/meraki/dashboard_spec.cr b/drivers/cisco/meraki/dashboard_spec.cr index 7a2340f2039..bdb18cc485c 100644 --- a/drivers/cisco/meraki/dashboard_spec.cr +++ b/drivers/cisco/meraki/dashboard_spec.cr @@ -1,8 +1,19 @@ DriverSpecs.mock_driver "Cisco::Meraki::Dashboard" do + # The dashboard should request the floorplan sizes on load + expect_http_request do |request, response| + headers = request.headers + if headers["X-Cisco-Meraki-API-Key"]? == "configure for the dashboard API" + response.status_code = 200 + response << %([{"floorPlanId":"floor-123","name":"Level 1","width":30.5,"height":20}]) + else + response.status_code = 401 + end + end + # Send the request retval = exec(:fetch, "/api/v0/organizations") - # The dashboard should send a HTTP request + # The dashboard should send a HTTP request with the API key expect_http_request do |request, response| headers = request.headers if headers["X-Cisco-Meraki-API-Key"]? == "configure for the dashboard API" diff --git a/drivers/cisco/meraki/scanning_api.cr b/drivers/cisco/meraki/scanning_api.cr index b68b6ab8e08..a5cd0316932 100644 --- a/drivers/cisco/meraki/scanning_api.cr +++ b/drivers/cisco/meraki/scanning_api.cr @@ -16,6 +16,12 @@ module Cisco::Meraki class Location include JSON::Serializable + # NOTE:: This is not part of the location response, + # it is here to simplify processing + @[JSON::Field(ignore: true)] + property mac : String? + + # Multiple types as the location when parsed might include javascript `"NaN"` property x : Float64 | String | Nil property y : Float64 | String | Nil property lng : Float64? diff --git a/drivers/place/area_config.cr b/drivers/place/area_config.cr new file mode 100644 index 00000000000..1d485f7c2e6 --- /dev/null +++ b/drivers/place/area_config.cr @@ -0,0 +1,54 @@ +require "json" +require "./area_polygon" + +module Place + class Geometry + include JSON::Serializable + + def initialize(@coordinates, @geo_type = "Polygon") + end + + @[JSON::Field(key: "type")] + property geo_type : String + property coordinates : Array(Tuple(Float64, Float64)) + end + + class AreaConfig + include JSON::Serializable + + def initialize(@id, name, coordinates, building_id = nil, @area_type = "Feature", @feature_type = "section") + @geometry = Geometry.new(coordinates) + @properties = { + "name" => name, + } + @properties["building_id"] = building_id if building_id + end + + @polygon : Polygon? = nil + + property id : String + + @[JSON::Field(key: "type")] + property area_type : String + property feature_type : String + + property geometry : Geometry + property properties : Hash(String, String) + + def name + self.properties["name"] + end + + def building + self.properties["building_id"]? + end + + def coordinates + self.geometry.coordinates + end + + def polygon : Polygon + @Polygon ||= Polygon.new(coordinates.map { |coords| Point.new(*coords) }) + end + end +end diff --git a/drivers/place/area_count.cr b/drivers/place/area_count.cr index c757614480d..5feee170d0d 100644 --- a/drivers/place/area_count.cr +++ b/drivers/place/area_count.cr @@ -1,5 +1,6 @@ module Place; end +require "./area_config" require "./area_polygon" class Place::AreaCount < PlaceOS::Driver @@ -8,53 +9,126 @@ class Place::AreaCount < PlaceOS::Driver description %(counts trackable objects in an area, such as people) default_settings({ - areas: [{ - id: "lobby1", - name: "George St Lobby", - building: "zone-12345", - level: "zone-34565", - boundary: [{3, 5}, {5, 6}, {6, 1}], - }], + building: "", + + username: "", + password: "", + client_id: "", + client_secret: "", + + # time in seconds + poll_rate: 60, + + # Driver to query + location_service: "LocationServices", + + areas: { + "zone-1234" => [ + { + id: "lobby1", + name: "George St Lobby", + building: "building-zone-id", + coordinates: [{3, 5}, {5, 6}, {6, 1}], + }, + ], + }, }) alias AreaSetting = NamedTuple( id: String, name: String, building: String?, - level: String?, - boundary: Array(Tuple(Float64, Float64))) + coordinates: Array(Tuple(Float64, Float64))) - alias Area = NamedTuple( - id: String, - name: String, - building: String?, - level: String?, - boundary: Polygon) + # zone_id => areas + @level_areas : Hash(String, Array(AreaConfig)) = {} of String => Array(AreaConfig) + # area_id => area + @areas : Hash(String, AreaConfig) = {} of String => AreaConfig + + # PlaceOS client config + @building_id : String = "" + @username : String = "" + @password : String = "" + @client_id : String = "" + @client_secret : String = "" - @boundaries : Hash(String, Area) = {} of String => Area + @poll_rate : Time::Span = 60.seconds + @location_service : String = "LocationServices" def on_load on_update end def on_update - areas = setting(Array(AreaSetting), :areas) - - @boundaries.clear - areas.each do |area| - points = area[:boundary].map { |p| Point.new(*p) } - @boundaries[area[:id]] = { - id: area[:id], - name: area[:name], - building: area[:building], - level: area[:level], - boundary: Polygon.new(points), - } + @poll_rate = (setting?(Int32, :poll_rate) || 60).seconds + @location_service = setting?(String, :location_service).presence || @location_service + + if building = setting?(String, :building).presence + # We expect the configuration to be stored in the zone metadata + # we use the Place Client to extract the data + @building_id = building + @username = setting(String, :username) + @password = setting(String, :password) + @client_id = setting(String, :client_id) + @client_secret = setting(String, :client_secret) + + # TODO:: grab the zone metadata + else + # Zones are defined in settings, this is mainly here so we can write specs + areas = setting(Hash(String, Array(AreaSetting)), :areas) + @level_areas.clear + areas.each do |zone_id, areas| + @level_areas[zone_id] = areas.map do |area| + config = AreaConfig.new(area[:id], area[:name], area[:coordinates], area[:building]) + @areas[config.id] = config + config + end + end end + + schedule.clear + schedule.every(@poll_rate) { request_locations } + end + + protected def location_service + system[@location_service] + end + + def request_locations + @level_areas.each do |level_id, areas| + begin + locations = location_service.device_locations(level_id).get.as_a + self[level_id] = { + value: locations, + ts_hint: "complex", + ts_map: { + x: "xloc", + y: "yloc", + }, + ts_fields: { + level: level_id, + }, + ts_tags: { + building: areas.first.building + } + } + areas.each do |area| + + end + rescue error + log_location_parsing(error, level_id) + sleep 200.milliseconds + end + end + end + + protected def log_location_parsing(error, level_id) + logger.debug(exception: error) { "while parsing #{level_id}" } + rescue end def is_inside?(x : Float64, y : Float64, area_id : String) - area = @boundaries[area_id] - area[:boundary].contains(x, y) + area = @areas[area_id] + area.polygon.contains(x, y) end end diff --git a/drivers/place/location_services.cr b/drivers/place/location_services.cr index 82f1184b947..3b42408ec7e 100644 --- a/drivers/place/location_services.cr +++ b/drivers/place/location_services.cr @@ -18,4 +18,38 @@ class Place::LocationServices < PlaceOS::Driver end located end + + # Will return an array of MAC address strings + # lowercase with no seperation characters abcdeffd1234 etc + def macs_assigned_to(email : String? = nil, username : String? = nil) + logger.debug { "listing MAC addresses assigned to #{email}, #{username}" } + macs = [] of String + system.implementing(Interface::Locatable).macs_assigned_to(email, username).get.each do |found| + macs.concat found.as_a.map(&.as_s) + end + macs + end + + # Will return `nil` or `{"location": "wireless", "assigned_to": "bob123", "mac_address": "abcd"}` + def check_ownership_of(mac_address : String) + logger.debug { "searching for owner of #{mac_address}" } + owner = nil + system.implementing(Interface::Locatable).check_ownership_of(mac_address).get.each do |result| + if result != nil + owner = result + break + end + end + owner + end + + # Will return an array of devices and their x, y coordinates + def device_locations(zone_id : String, location : String? = nil) + logger.debug { "searching devices in zone #{zone_id}" } + located = [] of JSON::Any + system.implementing(Interface::Locatable).device_locations(zone_id, location).get.each do |locations| + located.concat locations.as_a + end + located + end end From e460c7cb9111cf2fc42d7ce18935601279dc4037 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Fri, 16 Oct 2020 09:44:26 +1100 Subject: [PATCH 4/5] initial work on S2 cells --- drivers/cisco/meraki/dashboard.cr | 26 ++++++++++++++++++++++---- drivers/place/area_count.cr | 30 ++++++++++++++++++------------ shard.lock | 4 ++++ shard.yml | 4 ++++ 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/drivers/cisco/meraki/dashboard.cr b/drivers/cisco/meraki/dashboard.cr index 158a2c3b61a..d3d4bf50ada 100644 --- a/drivers/cisco/meraki/dashboard.cr +++ b/drivers/cisco/meraki/dashboard.cr @@ -4,6 +4,7 @@ module Cisco::Meraki; end require "uri" require "json" +require "s2_cells" require "link-header" require "./scanning_api" require "placeos-driver/interface/locatable" @@ -46,6 +47,11 @@ class Cisco::Meraki::Dashboard < PlaceOS::Driver # Max requests a second made to the dashboard rate_limit: 4, + # Area index each point on a floor lands on + # 21 == ~4 meters squared, which given wifi variance is good enough for tracing + # S2 cell levels: https://s2geometry.io/resources/s2cell_statistics.html + s2_level: 21, + # Level mappings, level name for human readability floorplan_mappings: { "g_727894289773756672" => { @@ -88,6 +94,8 @@ class Cisco::Meraki::Dashboard < PlaceOS::Driver @floorplan_sizes = {} of String => FloorPlan @max_location_age : Time::Span = 10.minutes + @s2_level : Int32 = 21 + def on_update @scanning_validator = setting?(String, :meraki_validator) || "" @scanning_secret = setting?(String, :meraki_secret) || "" @@ -112,6 +120,8 @@ class Cisco::Meraki::Dashboard < PlaceOS::Driver @floorplan_mappings = setting?(Hash(String, Hash(String, String)), :floorplan_mappings) || @floorplan_mappings @max_location_age = (setting?(UInt32, :max_location_age) || 10).minutes + @s2_level = setting?(Int32, :s2_level) || 21 + schedule.clear if @default_network.presence schedule.every(2.minutes) { map_users_to_macs } @@ -350,13 +360,17 @@ class Cisco::Meraki::Dashboard < PlaceOS::Driver }.sort { |a, b| b.time <=> a.time }.map { |location| + lat = location.lat + lon = location.lng + loc = { "location" => "wireless", "coordinates_from" => "bottom-left", "x" => location.x, "y" => location.y, - "lon" => location.lng, - "lat" => location.lat, + "lon" => lon, + "lat" => lat, + "s2_cell_id" => S2Cells::LatLon.new(lat, lon).to_token(@s2_level), "mac" => location.mac, "variance" => location.variance, "last_seen" => location.time.to_unix, @@ -408,14 +422,18 @@ class Cisco::Meraki::Dashboard < PlaceOS::Driver map_height = map_size.height end + lat = location.lat + lon = location.lng + locations.map do |location| { location: :wireless, coordinates_from: "bottom-left", x: location.x, y: location.y, - lon: location.lng, - lat: location.lat, + lon: lon, + lat: lat, + s2_cell_id: S2Cells::LatLon.new(lat, lon).to_token(@s2_level), mac: location.mac, variance: location.variance, last_seen: location.time.to_unix, diff --git a/drivers/place/area_count.cr b/drivers/place/area_count.cr index 5feee170d0d..70d1109f01e 100644 --- a/drivers/place/area_count.cr +++ b/drivers/place/area_count.cr @@ -11,9 +11,10 @@ class Place::AreaCount < PlaceOS::Driver default_settings({ building: "", - username: "", - password: "", - client_id: "", + # PlaceOS API creds, so we can query the zone metadata + username: "", + password: "", + client_id: "", client_secret: "", # time in seconds @@ -25,9 +26,9 @@ class Place::AreaCount < PlaceOS::Driver areas: { "zone-1234" => [ { - id: "lobby1", - name: "George St Lobby", - building: "building-zone-id", + id: "lobby1", + name: "George St Lobby", + building: "building-zone-id", coordinates: [{3, 5}, {5, 6}, {6, 1}], }, ], @@ -97,23 +98,28 @@ class Place::AreaCount < PlaceOS::Driver def request_locations @level_areas.each do |level_id, areas| begin + # Get location data for the level locations = location_service.device_locations(level_id).get.as_a + + # Provide to the frontend self[level_id] = { - value: locations, + value: locations, ts_hint: "complex", - ts_map: { + ts_map: { x: "xloc", y: "yloc", }, - ts_fields: { + ts_tag_keys: {"s2_cell_id"}, + ts_fields: { level: level_id, }, ts_tags: { - building: areas.first.building - } + building: areas.first.building, + }, } - areas.each do |area| + # Calculate the device counts for each area + areas.each do |area| end rescue error log_location_parsing(error, level_id) diff --git a/shard.lock b/shard.lock index 3656022f245..bf692b2be07 100644 --- a/shard.lock +++ b/shard.lock @@ -144,6 +144,10 @@ shards: git: https://github.com/spider-gazelle/readers-writer.git version: 1.0.6 + s2_cells: + git: https://github.com/spider-gazelle/s2_cells.git + version: 1.0.0 + simple_retry: git: https://github.com/spider-gazelle/simple_retry.git version: 1.1.0 diff --git a/shard.yml b/shard.yml index b3e235131d0..80296d1e3f5 100644 --- a/shard.yml +++ b/shard.yml @@ -47,3 +47,7 @@ dependencies: # Driver deps: telnet: github: spider-gazelle/telnet.cr + + # For lat lon location indexing + s2_cells: + github: spider-gazelle/s2_cells From 80de24cc204b36251a93789dedae5c4006806538 Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Tue, 20 Oct 2020 11:53:47 +1100 Subject: [PATCH 5/5] feat(area management): complete area management grabs location data count how many devices in an area provide a summary of the building --- drivers/place/area_count.cr | 140 --------- drivers/place/area_management.cr | 286 ++++++++++++++++++ ..._count_spec.cr => area_management_spec.cr} | 0 shard.lock | 48 ++- shard.yml | 5 + 5 files changed, 337 insertions(+), 142 deletions(-) delete mode 100644 drivers/place/area_count.cr create mode 100644 drivers/place/area_management.cr rename drivers/place/{area_count_spec.cr => area_management_spec.cr} (100%) diff --git a/drivers/place/area_count.cr b/drivers/place/area_count.cr deleted file mode 100644 index 70d1109f01e..00000000000 --- a/drivers/place/area_count.cr +++ /dev/null @@ -1,140 +0,0 @@ -module Place; end - -require "./area_config" -require "./area_polygon" - -class Place::AreaCount < PlaceOS::Driver - descriptive_name "PlaceOS Area Counter" - generic_name :Counter - description %(counts trackable objects in an area, such as people) - - default_settings({ - building: "", - - # PlaceOS API creds, so we can query the zone metadata - username: "", - password: "", - client_id: "", - client_secret: "", - - # time in seconds - poll_rate: 60, - - # Driver to query - location_service: "LocationServices", - - areas: { - "zone-1234" => [ - { - id: "lobby1", - name: "George St Lobby", - building: "building-zone-id", - coordinates: [{3, 5}, {5, 6}, {6, 1}], - }, - ], - }, - }) - - alias AreaSetting = NamedTuple( - id: String, - name: String, - building: String?, - coordinates: Array(Tuple(Float64, Float64))) - - # zone_id => areas - @level_areas : Hash(String, Array(AreaConfig)) = {} of String => Array(AreaConfig) - # area_id => area - @areas : Hash(String, AreaConfig) = {} of String => AreaConfig - - # PlaceOS client config - @building_id : String = "" - @username : String = "" - @password : String = "" - @client_id : String = "" - @client_secret : String = "" - - @poll_rate : Time::Span = 60.seconds - @location_service : String = "LocationServices" - - def on_load - on_update - end - - def on_update - @poll_rate = (setting?(Int32, :poll_rate) || 60).seconds - @location_service = setting?(String, :location_service).presence || @location_service - - if building = setting?(String, :building).presence - # We expect the configuration to be stored in the zone metadata - # we use the Place Client to extract the data - @building_id = building - @username = setting(String, :username) - @password = setting(String, :password) - @client_id = setting(String, :client_id) - @client_secret = setting(String, :client_secret) - - # TODO:: grab the zone metadata - else - # Zones are defined in settings, this is mainly here so we can write specs - areas = setting(Hash(String, Array(AreaSetting)), :areas) - @level_areas.clear - areas.each do |zone_id, areas| - @level_areas[zone_id] = areas.map do |area| - config = AreaConfig.new(area[:id], area[:name], area[:coordinates], area[:building]) - @areas[config.id] = config - config - end - end - end - - schedule.clear - schedule.every(@poll_rate) { request_locations } - end - - protected def location_service - system[@location_service] - end - - def request_locations - @level_areas.each do |level_id, areas| - begin - # Get location data for the level - locations = location_service.device_locations(level_id).get.as_a - - # Provide to the frontend - self[level_id] = { - value: locations, - ts_hint: "complex", - ts_map: { - x: "xloc", - y: "yloc", - }, - ts_tag_keys: {"s2_cell_id"}, - ts_fields: { - level: level_id, - }, - ts_tags: { - building: areas.first.building, - }, - } - - # Calculate the device counts for each area - areas.each do |area| - end - rescue error - log_location_parsing(error, level_id) - sleep 200.milliseconds - end - end - end - - protected def log_location_parsing(error, level_id) - logger.debug(exception: error) { "while parsing #{level_id}" } - rescue - end - - def is_inside?(x : Float64, y : Float64, area_id : String) - area = @areas[area_id] - area.polygon.contains(x, y) - end -end diff --git a/drivers/place/area_management.cr b/drivers/place/area_management.cr new file mode 100644 index 00000000000..5276a4f8210 --- /dev/null +++ b/drivers/place/area_management.cr @@ -0,0 +1,286 @@ +module Place; end + +require "placeos" +require "./area_config" +require "./area_polygon" + +class Place::AreaManagement < PlaceOS::Driver + descriptive_name "PlaceOS Area Management" + generic_name :AreaManagement + description %(counts trackable objects, such as laptops, in building areas) + + default_settings({ + building: "zone-12345", + + # PlaceOS API creds, so we can query the zone metadata + placeos_domain: "https://domain.name", + username: "", + password: "", + client_id: "", + client_secret: "", + + # time in seconds + poll_rate: 60, + + # How many wireless devices should we ignore + duplication_factor: 0.8, + + # Driver to query + location_service: "LocationServices", + + areas: { + "zone-1234" => [ + { + id: "lobby1", + name: "George St Lobby", + building: "building-zone-id", + coordinates: [{3, 5}, {5, 6}, {6, 1}], + }, + ], + }, + }) + + alias AreaSetting = NamedTuple( + id: String, + name: String, + building: String?, + coordinates: Array(Tuple(Float64, Float64))) + + alias AreaDetails = NamedTuple( + area_id: String, + name: String, + count: Int32, + ) + + alias LevelCapacity = NamedTuple( + total_desks: Int32, + total_capacity: Int32, + desk_ids: Array(String), + ) + + alias RawLevelDetails = NamedTuple( + wireless_devices: Int32, + desk_usage: Int32, + capacity: LevelCapacity, + ) + + # zone_id => areas + @level_areas : Hash(String, Array(AreaConfig)) = {} of String => Array(AreaConfig) + # area_id => area + @areas : Hash(String, AreaConfig) = {} of String => AreaConfig + + # zone_id => desk_ids + @duplication_factor : Float64 = 0.8 + @level_details : Hash(String, LevelCapacity) = {} of String => LevelCapacity + + # PlaceOS client config + @building_id : String = "" + getter! client : PlaceOS::Client + + @poll_rate : Time::Span = 60.seconds + @location_service : String = "LocationServices" + + def on_load + on_update + end + + def on_update + @building_id = setting(String, :building) + + @poll_rate = (setting?(Int32, :poll_rate) || 60).seconds + @location_service = setting?(String, :location_service).presence || "LocationServices" + @duplication_factor = setting?(Float64, :duplication_factor) || 0.8 + + # We expect the configuration to be stored in the zone metadata + # we use the Place Client to extract the data + username = setting(String, :username) + password = setting(String, :password) + client_id = setting(String, :client_id) + client_secret = setting(String, :client_secret) + placeos_domain = setting(String, :placeos_domain) + @client = PlaceOS::Client.new(placeos_domain, + email: username, + password: password, + client_id: client_id, + client_secret: client_secret + ) + + # Areas are defined in metadata, this is mainly here so we can write specs + if areas = setting?(Hash(String, Array(AreaSetting)), :areas) + @level_areas.clear + areas.each do |zone_id, areas| + @level_areas[zone_id] = areas.map do |area| + config = AreaConfig.new(area[:id], area[:name], area[:coordinates], area[:building]) + @areas[config.id] = config + config + end + end + end + + schedule.clear + schedule.every(@poll_rate) { request_locations } + end + + protected def location_service + system[@location_service] + end + + def request_locations + # Attempt to obtain the latest version of the metadata + begin + response = client.metadata.children("zone-EmWLJNm0i~6") + + @level_details.clear + response.each do |meta| + zone = meta[:zone] + next unless zone.tags.includes?("level") + + if desks = meta[:metadata]["desks"]? + ids = desks.details.as_a.map { |desk| desk["id"].as_s } + @level_details[zone.id] = { + total_desks: ids.size, + total_capacity: zone.capacity, + desk_ids: ids, + } + else + @level_details[zone.id] = { + total_desks: zone.count, + total_capacity: zone.capacity, + desk_ids: [] of String, + } + end + end + rescue error + logger.error(exception: error) { "obtaining level metadata" } + end + + # level => user count + level_counts = {} of String => RawLevelDetails + + @level_details.each do |level_id, details| + areas = @level_areas[level_id]? || [] of AreaConfig + + begin + # Provide the frontend with the list of all known desk ids on a level + self["#{level_id}:desk_ids"] = details[:desk_ids] + + # Get location data for the level + locations = location_service.device_locations(level_id).get.as_a + building_id = areas.first.building + + # Provide to the frontend + self[level_id] = { + value: locations, + ts_hint: "complex", + ts_map: { + x: "xloc", + y: "yloc", + }, + ts_tag_keys: {"s2_cell_id"}, + ts_fields: { + pos_level: level_id, + }, + ts_tags: { + pos_building: building_id, + }, + } + + # Grab the x,y locations + wireless_count = 0 + desk_count = 0 + xy_locs = locations.select do |loc| + case loc["location"].as_s + when "wireless" + wireless_count += 1 + + # Keep if x, y coords are present + !loc["x"].raw.nil? + when "desk" + desk_count += 1 + false + else + false + end + end + + # build the level overview + level_counts[level_id] = { + wireless_devices: wireless_count, + desk_usage: desk_count, + capacity: details, + } + + # Calculate the device counts for each area + area_counts = [] of AreaDetails + areas.each do |area| + count = 0 + + polygon = area.polygon + xy_locs.each do |loc| + count += 1 if polygon.contains(loc["x"].as_f, loc["y"].as_f) + end + + area_counts << { + area_id: area.id, + name: area.name, + count: count, + } + end + + # Provide the frontend the area details + self["#{level_id}-areas"] = { + value: area_counts, + ts_hint: "complex", + ts_fields: { + pos_level: level_id, + }, + ts_tags: { + pos_building: building_id, + }, + } + rescue error + log_location_parsing(error, level_id) + sleep 200.milliseconds + end + end + + self[:overview] = level_counts.transform_values { |details| build_level_stats(**details) } + end + + protected def log_location_parsing(error, level_id) + logger.debug(exception: error) { "while parsing #{level_id}" } + rescue + end + + def is_inside?(x : Float64, y : Float64, area_id : String) + area = @areas[area_id] + area.polygon.contains(x, y) + end + + protected def build_level_stats(wireless_devices, desk_usage, capacity) + # raw data + total_desks = capacity[:total_desks] + total_capacity = capacity[:total_capacity] + + # normalised data + adjusted_devices = wireless_devices * @duplication_factor + percentage_use = (adjusted_devices / total_capacity) * 100.0 + + # recommendations + individual_impact = 100.0 / total_capacity + remaining_capacity = total_capacity - adjusted_devices + recommendation = remaining_capacity + remaining_capacity * individual_impact + + { + desk_count: total_desks, + desk_usage: desk_usage, + device_capacity: total_capacity, + device_count: wireless_devices, + estimated_people: adjusted_devices.to_i, + percentage_use: percentage_use, + + # higher the number, higher the recommendation + recommendation: recommendation, + } + end +end diff --git a/drivers/place/area_count_spec.cr b/drivers/place/area_management_spec.cr similarity index 100% rename from drivers/place/area_count_spec.cr rename to drivers/place/area_management_spec.cr diff --git a/shard.lock b/shard.lock index bf692b2be07..96057e67d81 100644 --- a/shard.lock +++ b/shard.lock @@ -1,9 +1,17 @@ version: 2.0 shards: + CrystalEmail: + git: https://github.com/Nephos/CrystalEmail.git + version: 0.2.4 + action-controller: git: https://github.com/spider-gazelle/action-controller.git version: 3.4.0 + active-model: + git: https://github.com/spider-gazelle/active-model.git + version: 2.0.2 + bindata: git: https://github.com/spider-gazelle/bindata.git version: 1.7.0 @@ -24,6 +32,10 @@ shards: git: https://github.com/kostya/cron_parser.git version: 0.3.0 + db: + git: https://github.com/crystal-lang/crystal-db.git + version: 0.10.0 + debug: git: https://github.com/Sija/debug.cr.git version: 2.0.0 @@ -56,10 +68,18 @@ shards: git: https://github.com/place-labs/hound-dog.git version: 2.5.3 + http-params-serializable: + git: https://github.com/vladfaust/http-params-serializable.git + version: 0.4.0 + ipaddress: git: https://github.com/Sija/ipaddress.cr.git version: 0.2.1 + json_mapping: + git: https://github.com/crystal-lang/json_mapping.cr.git + version: 0.1.0 + jwt: git: https://github.com/crystal-community/jwt.git version: 1.4.2 @@ -80,6 +100,10 @@ shards: git: https://github.com/aca-labs/murmur3.git version: 0.1.1+git.commit.7cbe25c0ca8d052c9d98c377c824dcb0e038c790 + neuroplastic: + git: https://github.com/spider-gazelle/neuroplastic.git + version: 1.7.1 + ntlm: git: https://github.com/spider-gazelle/ntlm.git version: 1.0.0 @@ -100,13 +124,21 @@ shards: git: https://github.com/PlaceOS/calendar.git version: 4.2.2 + placeos: + git: https://github.com/placeos/crystal-client.git + version: 2.1.6 + placeos-compiler: git: https://github.com/placeos/compiler.git version: 2.0.4 placeos-driver: git: https://github.com/placeos/driver.git - version: 3.5.16 + version: 3.5.17 + + placeos-models: + git: https://github.com/placeos/models.git + version: 4.5.0 pool: git: https://github.com/ysbaddaden/pool.git @@ -136,6 +168,14 @@ shards: git: https://github.com/caspiano/rendezvous-hash.git version: 0.3.1 + rethinkdb: + git: https://github.com/kingsleyh/crystal-rethinkdb.git + version: 0.2.1 + + rethinkdb-orm: + git: https://github.com/spider-gazelle/rethinkdb-orm.git + version: 3.1.1 + retriable: git: https://github.com/Sija/retriable.cr.git version: 0.2.2 @@ -158,7 +198,7 @@ shards: tasker: git: https://github.com/spider-gazelle/tasker.git - version: 2.0.1 + version: 2.0.2 telnet: git: https://github.com/spider-gazelle/telnet.cr.git @@ -172,3 +212,7 @@ shards: git: https://github.com/place-labs/ulid.git version: 0.1.2 + yaml_mapping: + git: https://github.com/crystal-lang/yaml_mapping.cr.git + version: 0.1.0 + diff --git a/shard.yml b/shard.yml index 80296d1e3f5..245de0f6fe3 100644 --- a/shard.yml +++ b/shard.yml @@ -41,6 +41,11 @@ dependencies: github: placeos/driver version: ~> 3.0 + # The PlaceOS API client + placeos: + github: placeos/crystal-client + version: ~> 2.1.6 + rwlock: github: spider-gazelle/readers-writer