Merge branch 'c3nav:main' into main
This commit is contained in:
commit
174a5af5f8
35 changed files with 959 additions and 163 deletions
|
@ -10,11 +10,12 @@ and potential backwards incompatibilities.
|
|||
|
||||
Big stuff:
|
||||
|
||||
- Quest support to categorize rooms, find AP altitudes and generate route descriptions
|
||||
- Quest support to categorize rooms, find AP altitudes, AP names, do wifi scanning and generate route descriptions
|
||||
- data overlay support
|
||||
- complete rewrite of editor changesets as a base for a more modern editor – you will lose all changesets!
|
||||
- new map settings API endpoint
|
||||
- ability to import APs from NOC eventmap
|
||||
- ability to import Antennas from POC
|
||||
|
||||
Semi-big stuff:
|
||||
|
||||
|
@ -29,6 +30,9 @@ Semi-big stuff:
|
|||
- support for various SSOs
|
||||
- various compliance checkboxes
|
||||
- support for importing projects and rooms from hub
|
||||
- match APs using name broadcast in Aruba vendor-data instead of just BSSIDs
|
||||
- fewer and more performant calls to redis
|
||||
- pruning redis cache automatically after a new map update is created
|
||||
|
||||
Small stuff:
|
||||
|
||||
|
@ -37,6 +41,8 @@ Small stuff:
|
|||
- Level short_label has been split into short_label (for displaying) and level_index (for internal use like coordinates)
|
||||
- some API mapdata endpoints were moved, some lesser used properties renamed
|
||||
- proper support for access restricted levels
|
||||
- ability to store mutiple BSSIDs per beacon
|
||||
- importhub can now import projects and rooms as well
|
||||
|
||||
Behind the scenes, comfort, bug fixes:
|
||||
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.0.8 on 2024-12-28 21:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('control', '0018_userpermissions_impolite_quests'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpermissions',
|
||||
name='passive_ap_name_scanning',
|
||||
field=models.BooleanField(default=False, verbose_name='passive ap name scanning'),
|
||||
),
|
||||
]
|
|
@ -44,6 +44,7 @@ class UserPermissions(models.Model):
|
|||
nonpublic_themes = models.BooleanField(default=False, verbose_name=_('show non-public themes in theme selector'))
|
||||
quests: list[str] = SchemaField(schema=list[str], default=list)
|
||||
impolite_quests = models.BooleanField(default=False, verbose_name=_('dont say thanks after completing a quest'))
|
||||
passive_ap_name_scanning = models.BooleanField(default=False, verbose_name=_('passive ap name scanning'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('User Permissions')
|
||||
|
|
|
@ -170,7 +170,7 @@ def beacons_lookup(request):
|
|||
"name": node.name if node else ("Beacon #%d" % beacon.pk),
|
||||
"point": mapping(beacon.geometry),
|
||||
}
|
||||
for bssid in beacon.wifi_bssids:
|
||||
for bssid in beacon.addresses:
|
||||
wifi_beacons[bssid] = beacon_data
|
||||
if beacon.ibeacon_uuid and beacon.ibeacon_major is not None and beacon.ibeacon_minor is not None:
|
||||
ibeacons.setdefault(
|
||||
|
|
|
@ -262,7 +262,8 @@ class EditorFormBase(I18nModelFormMixin, ModelForm):
|
|||
self.add_redirect_slugs = None
|
||||
self.remove_redirect_slugs = None
|
||||
if 'slug' in self.fields:
|
||||
self.redirect_slugs = (sorted(self.instance.redirects.values_list('slug', flat=True))
|
||||
self.redirect_slugs = (sorted(slug for slug in self.instance.redirects.values_list('slug', flat=True)
|
||||
if slug) # THIS SHOULD NEVER BE NONE
|
||||
if self.instance.pk else [])
|
||||
self.fields['redirect_slugs'] = CharField(label=_('Redirecting Slugs (comma separated)'), required=False,
|
||||
initial=','.join(self.redirect_slugs))
|
||||
|
@ -384,12 +385,12 @@ def create_editor_form(editor_model):
|
|||
'ordering', 'category', 'width', 'groups', 'height', 'color', 'in_legend', 'priority', 'hierarchy', 'icon_name',
|
||||
'base_altitude', 'intermediate', 'waytype', 'access_restriction', 'edit_access_restriction', 'default_height',
|
||||
'door_height', 'outside', 'identifyable', 'can_search', 'can_describe', 'geometry', 'single', 'altitude',
|
||||
'level_index', 'short_label', 'origin_space', 'target_space', 'data', 'comment', 'slow_down_factor',
|
||||
'groundaltitude', 'node_number', 'wifi_bssids', 'bluetooth_address', 'group', 'ibeacon_uuid', 'ibeacon_major',
|
||||
'ibeacon_minor', 'uwb_address', 'extra_seconds', 'speed', 'can_report_missing', 'can_report_mistake',
|
||||
'description', 'speed_up', 'description_up', 'avoid_by_default', 'report_help_text', 'enter_description',
|
||||
'level_change_description', 'base_mapdata_accessible', 'label_settings', 'label_override', 'min_zoom',
|
||||
'max_zoom', 'font_size', 'members', 'allow_levels', 'allow_spaces', 'allow_areas', 'allow_pois',
|
||||
"beacon_type", 'level_index', 'short_label', 'origin_space', 'target_space', 'data', "ap_name", 'comment',
|
||||
'slow_down_factor', 'groundaltitude', 'node_number', 'addresses', 'bluetooth_address', 'group',
|
||||
'ibeacon_uuid', 'ibeacon_major', 'ibeacon_minor', 'uwb_address', 'extra_seconds', 'speed', 'can_report_missing',
|
||||
'can_report_mistake', 'description', 'speed_up', 'description_up', 'avoid_by_default', 'report_help_text',
|
||||
'enter_description', 'level_change_description', 'base_mapdata_accessible', 'label_settings', 'label_override',
|
||||
'min_zoom', 'max_zoom', 'font_size', 'members', 'allow_levels', 'allow_spaces', 'allow_areas', 'allow_pois',
|
||||
'allow_dynamic_locations', 'left', 'top', 'right', 'bottom', 'import_tag', 'import_block_data',
|
||||
'import_block_geom', 'public', 'default', 'dark', 'high_contrast', 'funky', 'randomize_primary_color',
|
||||
'color_logo', 'color_css_initial', 'color_css_primary', 'color_css_secondary', 'color_css_tertiary',
|
||||
|
|
|
@ -7,8 +7,8 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-27 13:48+0100\n"
|
||||
"PO-Revision-Date: 2024-12-27 14:44+0100\n"
|
||||
"POT-Creation-Date: 2024-12-28 18:12+0100\n"
|
||||
"PO-Revision-Date: 2024-12-28 18:20+0100\n"
|
||||
"Last-Translator: Laura Klünder <laura@codingcatgirl.de>\n"
|
||||
"Language-Team: \n"
|
||||
"Language: de\n"
|
||||
|
@ -709,6 +709,10 @@ msgstr ""
|
|||
"Umleitungs-Slug „%s“ kann nicht hinzugefügt werden: Er wird bereits an "
|
||||
"anderer Stelle verwendet."
|
||||
|
||||
#: c3nav/editor/forms.py
|
||||
msgid "Why is there WiFi scan data if this is a fill quest?"
|
||||
msgstr "Warum sind WLAN daten in einem Füll Quest?"
|
||||
|
||||
#: c3nav/editor/forms.py
|
||||
msgid "WiFi scan data is missing."
|
||||
msgstr "WiFI Scandaten fehlen."
|
||||
|
@ -2124,6 +2128,10 @@ msgstr "Kommentar"
|
|||
msgid "Measurement list"
|
||||
msgstr "Messungsliste"
|
||||
|
||||
#: c3nav/mapdata/models/geometry/space.py
|
||||
msgid "create a quest to fill this"
|
||||
msgstr "Füll-Quest erstellen"
|
||||
|
||||
#: c3nav/mapdata/models/geometry/space.py
|
||||
msgid "Beacon Measurement"
|
||||
msgstr "Beacon Messung"
|
||||
|
@ -2132,13 +2140,25 @@ msgstr "Beacon Messung"
|
|||
msgid "Beacon Measurements"
|
||||
msgstr "Beacon Messungen"
|
||||
|
||||
#: c3nav/mapdata/models/geometry/space.py
|
||||
msgid "Event WiFi AP"
|
||||
msgstr "Event-WLAN AP"
|
||||
|
||||
#: c3nav/mapdata/models/geometry/space.py
|
||||
msgid "DECT antenna"
|
||||
msgstr "DECT Antenne"
|
||||
|
||||
#: c3nav/mapdata/models/geometry/space.py
|
||||
msgid "beacon type"
|
||||
msgstr "Beacon-Typ"
|
||||
|
||||
#: c3nav/mapdata/models/geometry/space.py
|
||||
msgid "Node Number"
|
||||
msgstr "Node Nummer"
|
||||
|
||||
#: c3nav/mapdata/models/geometry/space.py
|
||||
msgid "WiFi BSSIDs"
|
||||
msgstr "WLAN BSSIDs"
|
||||
msgid "Mac Address / BSSIDs"
|
||||
msgstr "Mac-Adresse / BSSIDs"
|
||||
|
||||
#: c3nav/mapdata/models/geometry/space.py
|
||||
msgid "uses node's value if not set"
|
||||
|
@ -2164,6 +2184,10 @@ msgstr "iBeacon minor value"
|
|||
msgid "UWB Address"
|
||||
msgstr "UWB Adresse"
|
||||
|
||||
#: c3nav/mapdata/models/geometry/space.py
|
||||
msgid "AP name"
|
||||
msgstr "AP Name"
|
||||
|
||||
#: c3nav/mapdata/models/geometry/space.py
|
||||
msgid "altitude quest"
|
||||
msgstr "Höhenquest"
|
||||
|
@ -2976,12 +3000,12 @@ msgstr "Kartenänderung"
|
|||
|
||||
#: c3nav/mapdata/quests/positioning.py
|
||||
#, python-format
|
||||
msgid "How many meters above ground is the access point “%s” mounted?"
|
||||
msgstr "Wie viele meter über dem Boden ist der access point „%s“ befestigt?"
|
||||
msgid "How many meters above ground is “%s” mounted?"
|
||||
msgstr "Wie viele meter über dem Boden ist „%s“ befestigt?"
|
||||
|
||||
#: c3nav/mapdata/quests/positioning.py
|
||||
msgid "The AP should not be 0m above ground."
|
||||
msgstr "Der AP sollte sich nicht 0m über dem Boden befinden."
|
||||
msgid "The device should not be 0m above ground."
|
||||
msgstr "Das Gerät sollte sich nicht 0m über dem Boden befinden."
|
||||
|
||||
#: c3nav/mapdata/quests/positioning.py
|
||||
msgid "Ranging Beacon Altitude"
|
||||
|
@ -3018,6 +3042,30 @@ msgstr "Bitte lass diesen Dialog bis dahin offen."
|
|||
msgid "This should happen within less than a minute."
|
||||
msgstr "Dies sollte weniger als eine Minute dauern."
|
||||
|
||||
#: c3nav/mapdata/quests/positioning.py
|
||||
msgid "Need at least one scan."
|
||||
msgstr "Mindestens ein scan wird benötigt."
|
||||
|
||||
#: c3nav/mapdata/quests/positioning.py
|
||||
msgid "Wifi/BLE Positioning"
|
||||
msgstr "WLAN/BLE-Ortung"
|
||||
|
||||
#: c3nav/mapdata/quests/positioning.py
|
||||
msgid ""
|
||||
"Please stand as close to the given location as possible. Feel free to close "
|
||||
"this window again to double-check."
|
||||
msgstr ""
|
||||
"Bitte stehe so nah wie möglcih an dem angezeigten Punkt. Du kannst dieses "
|
||||
"Fenster schließen um dir die Position nochmal anzugucken."
|
||||
|
||||
#: c3nav/mapdata/quests/positioning.py
|
||||
msgid ""
|
||||
"When you're ready, please click the button below and wait for measurements "
|
||||
"to arrive."
|
||||
msgstr ""
|
||||
"Wenn du bereit bist, klicke auf den Butten unten und warte darauf, dass "
|
||||
"Messwerte erscheinen."
|
||||
|
||||
#: c3nav/mapdata/quests/route_descriptions.py
|
||||
msgid "Does this space qualify as “easily identifyable/findable”?"
|
||||
msgstr "Ist dieser Raum „einfach zu identifizieren/finden“?"
|
||||
|
@ -4614,6 +4662,9 @@ msgstr ""
|
|||
"API-Secret erstellt. Notier es dir sofort, denn es wird nicht erneut "
|
||||
"angezeigt!"
|
||||
|
||||
#~ msgid "WiFi BSSIDs"
|
||||
#~ msgstr "WLAN BSSIDs"
|
||||
|
||||
#~ msgid "WiFi scan data is not a list."
|
||||
#~ msgstr "WifFi Scandaten sind keine Liste."
|
||||
|
||||
|
|
|
@ -295,6 +295,18 @@ def get_position_by_id(request, position_id: AnyPositionID):
|
|||
return location.serialize_position(request=request)
|
||||
|
||||
|
||||
@map_api_router.get('/positions/my/', summary="all moving position coordinates",
|
||||
description="get current coordinates of all moving positions owned be the current users",
|
||||
response={200: list[AnyPositionStatusSchema], **API404.dict(), **auth_responses})
|
||||
@api_stats('get_position')
|
||||
def get_my_positions(request, position_id: AnyPositionID):
|
||||
# no caching for obvious reasons!
|
||||
return [
|
||||
position.serialize_position(request=request)
|
||||
for position in Position.objects.filter(owner=request.user)
|
||||
]
|
||||
|
||||
|
||||
class UpdatePositionSchema(BaseSchema):
|
||||
coordinates_id: Union[
|
||||
Annotated[CustomLocationID, APIField(title="set coordinates")],
|
||||
|
@ -332,7 +344,7 @@ def set_position(request, position_id: AnyPositionID, update: UpdatePositionSche
|
|||
raise APIRequestValidationFailed('Cant resolve coordinates.')
|
||||
|
||||
location.coordinates_id = update.coordinates_id
|
||||
location.timeout = update.timeout
|
||||
location.timeout = update.timeout or 0
|
||||
location.last_coordinates_update = timezone.now()
|
||||
location.save()
|
||||
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from c3nav.mapdata.models import MapUpdate
|
||||
from c3nav.mapdata.models.geometry.space import BeaconMeasurement
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'collect BSSIDS for AP names from measurements'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with transaction.atomic():
|
||||
with MapUpdate.lock():
|
||||
BeaconMeasurement.contribute_bssid_to_beacons(BeaconMeasurement.objects.all())
|
||||
MapUpdate.objects.create(type='bssids_from_scans_to_beacons')
|
|
@ -19,7 +19,7 @@ class Command(BaseCommand):
|
|||
found_beacons.setdefault(measurement["bssid"], []).append((beacon_measurement, measurement))
|
||||
|
||||
# put in the ones we know
|
||||
known = dict(chain(*(((bssid, r) for bssid in r.wifi_bssids) for r in RangingBeacon.objects.all())))
|
||||
known = dict(chain(*(((bssid, r) for bssid in r.addresses) for r in RangingBeacon.objects.all())))
|
||||
|
||||
# lets go through them
|
||||
for bssid, measurements in found_beacons.items():
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import hashlib
|
||||
from typing import Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.text import slugify
|
||||
from pydantic import BaseModel, Field, PositiveInt
|
||||
from shapely import Point
|
||||
from shapely.geometry import shape
|
||||
|
@ -13,7 +11,6 @@ from shapely.geometry import shape
|
|||
from c3nav.api.utils import NonEmptyStr
|
||||
from c3nav.mapdata.models import Area, LocationGroup, LocationSlug, MapUpdate, Space
|
||||
from c3nav.mapdata.models.geometry.space import POI
|
||||
from c3nav.mapdata.models.locations import LocationRedirect
|
||||
from c3nav.mapdata.models.report import Report
|
||||
from c3nav.mapdata.utils.cache.changes import changed_geometries
|
||||
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
import hashlib
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from pydantic import BaseModel
|
||||
from shapely import distance
|
||||
|
||||
from c3nav.mapdata.models import MapUpdate, Space, Level
|
||||
from c3nav.mapdata.models import MapUpdate
|
||||
from c3nav.mapdata.models.geometry.space import RangingBeacon
|
||||
from c3nav.mapdata.models.report import Report
|
||||
from c3nav.mapdata.utils.placement import PointPlacementHelper
|
||||
from c3nav.mapdata.utils.cache.changes import changed_geometries
|
||||
from c3nav.mapdata.utils.geometry import unwrap_geom
|
||||
from shapely.ops import nearest_points, unary_union
|
||||
|
||||
|
||||
class NocImportItem(BaseModel):
|
||||
|
@ -39,19 +36,12 @@ class Command(BaseCommand):
|
|||
self.do_import(items)
|
||||
MapUpdate.objects.create(type='importnoc')
|
||||
|
||||
def _get_space_geom(self, space):
|
||||
return space.geometry.difference(unary_union([unwrap_geom(hole.geometry) for hole in space.holes.all()]))
|
||||
|
||||
def do_import(self, items: dict[str, NocImportItem]):
|
||||
spaces_for_level = {}
|
||||
levels = tuple(Level.objects.values_list("pk", flat=True))
|
||||
lower_levels_for_level = {pk: levels[:i] for i, pk in enumerate(levels)}
|
||||
|
||||
for space in Space.objects.select_related('level').prefetch_related('holes'):
|
||||
spaces_for_level.setdefault(space.level_id, []).append(space)
|
||||
import_helper = PointPlacementHelper()
|
||||
|
||||
beacons_so_far: dict[str, RangingBeacon] = {
|
||||
**{m.import_tag: m for m in RangingBeacon.objects.filter(import_tag__startswith="noc:")},
|
||||
**{m.import_tag: m for m in RangingBeacon.objects.filter(import_tag__startswith="noc:",
|
||||
beacon_type=RangingBeacon.BeaconType.EVENT_WIFI)},
|
||||
}
|
||||
|
||||
for name, item in items.items():
|
||||
|
@ -66,70 +56,37 @@ class Command(BaseCommand):
|
|||
print(f"ERROR: {name} has invalid layer: {item.layer}")
|
||||
continue
|
||||
|
||||
new_geometry = converter.convert(item.lat, item.lng)
|
||||
point = converter.convert(item.lat, item.lng)
|
||||
|
||||
# determine space
|
||||
possible_spaces = [space for space in spaces_for_level[converter.level_id]
|
||||
if space.geometry.intersects(new_geometry)]
|
||||
if not possible_spaces:
|
||||
possible_spaces = [space for space in spaces_for_level[converter.level_id]
|
||||
if distance(unwrap_geom(space.geometry), new_geometry) < 0.3]
|
||||
if len(possible_spaces) == 1:
|
||||
new_space = possible_spaces[0]
|
||||
the_distance = distance(unwrap_geom(new_space.geometry), new_geometry)
|
||||
print(f"SUCCESS: {name} is {the_distance:.02f}m away from {new_space.title}")
|
||||
elif len(possible_spaces) == 2:
|
||||
new_space = min(possible_spaces, key=lambda s: distance(unwrap_geom(s.geometry), new_geometry))
|
||||
print(f"WARNING: {name} could be in multiple spaces ({possible_spaces}, picking {new_space}...")
|
||||
else:
|
||||
print(f"ERROR: {name} is not within any space (NOC: {(item.lat, item.lng)}, NAV: {new_geometry}")
|
||||
continue
|
||||
new_space, point = import_helper.get_point_and_space(
|
||||
level_id=converter.level_id,
|
||||
point=point,
|
||||
name=name,
|
||||
)
|
||||
|
||||
# move point into space if needed
|
||||
if not new_space.geometry.intersects(new_geometry):
|
||||
new_geometry = nearest_points(new_space.geometry.buffer(-0.05), new_geometry)[0]
|
||||
elif len(possible_spaces) == 1:
|
||||
new_space = possible_spaces[0]
|
||||
print(f"SUCCESS: {name} is in {new_space.title}")
|
||||
else:
|
||||
print(f"WARNING: {name} could be in multiple spaces, picking one...")
|
||||
new_space = possible_spaces[0]
|
||||
|
||||
lower_levels = lower_levels_for_level[new_space.level_id]
|
||||
for lower_level in reversed(lower_levels):
|
||||
# let's go through the lower levels
|
||||
if not unary_union([unwrap_geom(h.geometry) for h in new_space.holes.all()]).intersects(new_geometry):
|
||||
# current selected spacae is fine, that's it
|
||||
break
|
||||
print(f"NOTE: {name} is in a hole, looking lower...")
|
||||
|
||||
# find a lower space
|
||||
possible_spaces = [space for space in spaces_for_level[lower_level]
|
||||
if space.geometry.intersects(new_geometry)]
|
||||
if possible_spaces:
|
||||
new_space = possible_spaces[0]
|
||||
print(f"NOTE: {name} moved to lower space {new_space}")
|
||||
else:
|
||||
print(f"WARNING: {name} couldn't find a lower space, still in a hole")
|
||||
if new_space is None:
|
||||
continue
|
||||
|
||||
# find existing location
|
||||
result = beacons_so_far.pop(import_tag, None)
|
||||
|
||||
old_result = None
|
||||
|
||||
# build resulting object
|
||||
altitude_quest = True
|
||||
if not result:
|
||||
old_result = result
|
||||
result = RangingBeacon(import_tag=import_tag)
|
||||
result = RangingBeacon(import_tag=import_tag, beacon_type=RangingBeacon.BeaconType.EVENT_WIFI)
|
||||
else:
|
||||
if result.space == new_space and distance(unwrap_geom(result.geometry), new_geometry) < 0.03:
|
||||
if result.space == new_space and distance(unwrap_geom(result.geometry), point) < 0.03:
|
||||
continue
|
||||
result.comment = name
|
||||
if result.space == new_space and distance(unwrap_geom(result.geometry), point) < 0.20:
|
||||
altitude_quest = False
|
||||
|
||||
result.ap_name = name
|
||||
result.space = new_space
|
||||
result.geometry = new_geometry
|
||||
result.geometry = point
|
||||
result.altitude = 0
|
||||
result.altitude_quest = True
|
||||
result.save() # todo: onyl save if changes… etc
|
||||
if altitude_quest:
|
||||
result.altitude_quest = True
|
||||
result.save()
|
||||
|
||||
for import_tag, location in beacons_so_far.items():
|
||||
location.delete()
|
||||
|
|
99
src/c3nav/mapdata/management/commands/importpoc.py
Normal file
99
src/c3nav/mapdata/management/commands/importpoc.py
Normal file
|
@ -0,0 +1,99 @@
|
|||
from typing import Literal
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from pydantic import BaseModel
|
||||
from pydantic.type_adapter import TypeAdapter
|
||||
from pydantic_extra_types.mac_address import MacAddress
|
||||
from shapely import distance
|
||||
from shapely.geometry import shape, Point
|
||||
|
||||
from c3nav.api.schema import PointSchema
|
||||
from c3nav.mapdata.models import MapUpdate, Level
|
||||
from c3nav.mapdata.models.geometry.space import RangingBeacon
|
||||
from c3nav.mapdata.utils.cache.changes import changed_geometries
|
||||
from c3nav.mapdata.utils.geometry import unwrap_geom
|
||||
from c3nav.mapdata.utils.placement import PointPlacementHelper
|
||||
|
||||
|
||||
class PocImportItemProperties(BaseModel):
|
||||
level: str
|
||||
mac: MacAddress
|
||||
name: str
|
||||
|
||||
|
||||
class PocImportItem(BaseModel):
|
||||
"""
|
||||
Something imported from the NOC
|
||||
"""
|
||||
type: Literal["Feature"] = "Feature"
|
||||
geometry: PointSchema
|
||||
properties: PocImportItemProperties
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'import APs from noc'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
r = requests.get(settings.POC_API_BASE+"/antenna-locations", headers={'ApiKey': settings.POC_API_SECRET})
|
||||
r.raise_for_status()
|
||||
items = TypeAdapter(list[PocImportItem]).validate_python(r.json())
|
||||
|
||||
with MapUpdate.lock():
|
||||
changed_geometries.reset()
|
||||
self.do_import(items)
|
||||
MapUpdate.objects.create(type='importnoc')
|
||||
|
||||
def do_import(self, items: list[PocImportItem]):
|
||||
import_helper = PointPlacementHelper()
|
||||
|
||||
beacons_so_far: dict[str, RangingBeacon] = {
|
||||
**{m.import_tag: m for m in RangingBeacon.objects.filter(import_tag__startswith="poc:",
|
||||
beacon_type=RangingBeacon.BeaconType.DECT)},
|
||||
}
|
||||
|
||||
levels_by_level_index = {str(level.level_index): level for level in Level.objects.all()}
|
||||
|
||||
for item in items:
|
||||
import_tag = f"poc:{item.properties.name}"
|
||||
|
||||
# determine geometry
|
||||
level_id = levels_by_level_index[item.properties.level].pk
|
||||
|
||||
point: Point = shape(item.geometry.model_dump()) # nowa
|
||||
|
||||
new_space, point = import_helper.get_point_and_space(
|
||||
level_id=level_id,
|
||||
point=point,
|
||||
name=item.properties.name,
|
||||
)
|
||||
|
||||
if new_space is None:
|
||||
continue
|
||||
|
||||
# find existing location
|
||||
result = beacons_so_far.pop(import_tag, None)
|
||||
|
||||
# build resulting object
|
||||
altitude_quest = True
|
||||
if not result:
|
||||
result = RangingBeacon(import_tag=import_tag, beacon_type=RangingBeacon.BeaconType.DECT)
|
||||
else:
|
||||
if result.space == new_space and distance(unwrap_geom(result.geometry), point) < 0.03:
|
||||
continue
|
||||
if result.space == new_space and distance(unwrap_geom(result.geometry), point) < 0.20:
|
||||
altitude_quest = False
|
||||
|
||||
result.ap_name = item.properties.name
|
||||
result.addresses = [item.properties.mac.lower()]
|
||||
result.space = new_space
|
||||
result.geometry = point
|
||||
result.altitude = 0
|
||||
if altitude_quest:
|
||||
result.altitude_quest = True
|
||||
result.save()
|
||||
|
||||
for import_tag, location in beacons_so_far.items():
|
||||
location.delete()
|
||||
print(f"NOTE: {import_tag} was deleted")
|
36
src/c3nav/mapdata/migrations/0134_rangingbeacon_ap_name.py
Normal file
36
src/c3nav/mapdata/migrations/0134_rangingbeacon_ap_name.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
# Generated by Django 5.0.8 on 2024-12-27 20:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def fill_ap_name(apps, schema_editor):
|
||||
RangingBeacon = apps.get_model('mapdata', 'rangingbeacon')
|
||||
for ranging_beacon in RangingBeacon.objects.filter(import_tag__startswith='noc:'):
|
||||
ranging_beacon.ap_name = ranging_beacon.import_tag[4:]
|
||||
if ranging_beacon.comment == ranging_beacon.import_tag[4:]:
|
||||
ranging_beacon.comment = None
|
||||
ranging_beacon.save()
|
||||
|
||||
|
||||
def unfill_ap_name(apps, schema_editor):
|
||||
RangingBeacon = apps.get_model('mapdata', 'rangingbeacon')
|
||||
for ranging_beacon in RangingBeacon.objects.filter(ap_name__isnull=False, import_tag__startswith='noc:'):
|
||||
if ranging_beacon.ap_name == ranging_beacon.import_tag[4:]:
|
||||
ranging_beacon.comment = ' '.join(((ranging_beacon.comment or ''), ranging_beacon.ap_name)).strip()
|
||||
ranging_beacon.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mapdata', '0133_beaconmeasurement_fill_quest'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rangingbeacon',
|
||||
name='ap_name',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='AP name'),
|
||||
),
|
||||
migrations.RunPython(fill_ap_name, unfill_ap_name),
|
||||
]
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 5.0.8 on 2024-12-28 13:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def add_beacon_type(apps, schema_editor):
|
||||
RangingBeacon = apps.get_model('mapdata', 'rangingbeacon')
|
||||
RangingBeacon.objects.filter(import_tag__startswith='noc:').update(beacon_type="event_wifi")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mapdata', '0134_rangingbeacon_ap_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rangingbeacon',
|
||||
name='beacon_type',
|
||||
field=models.CharField(blank=True, choices=[('event_wifi', 'Event WiFi AP'), ('dect', 'DECT antenna')], max_length=16, null=True, verbose_name='beacon type'),
|
||||
),
|
||||
migrations.RunPython(add_beacon_type, migrations.RunPython.noop),
|
||||
]
|
|
@ -0,0 +1,38 @@
|
|||
# Generated by Django 5.0.8 on 2024-12-28 14:15
|
||||
|
||||
import django.core.serializers.json
|
||||
import django_pydantic_field.compat.django
|
||||
import django_pydantic_field.fields
|
||||
import pydantic_extra_types.mac_address
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mapdata', '0135_rangingbeacon_beacon_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='rangingbeacon',
|
||||
old_name='wifi_bssids',
|
||||
new_name='addresses',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rangingbeacon',
|
||||
name='addresses',
|
||||
field=django_pydantic_field.fields.PydanticSchemaField(config=None, default=list,
|
||||
encoder=django.core.serializers.json.DjangoJSONEncoder,
|
||||
help_text="uses node's value if not set",
|
||||
schema=django_pydantic_field.compat.django.GenericContainer(
|
||||
list,
|
||||
(pydantic_extra_types.mac_address.MacAddress,)),
|
||||
verbose_name='Mac Address / BSSIDs'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rangingbeacon',
|
||||
name='ap_name',
|
||||
field=models.CharField(blank=True, max_length=32, null=True, verbose_name='AP name'),
|
||||
),
|
||||
]
|
31
src/c3nav/mapdata/migrations/0137_position_short_name.py
Normal file
31
src/c3nav/mapdata/migrations/0137_position_short_name.py
Normal file
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 5.0.8 on 2024-12-29 16:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def generate_short_name(apps, schema_editor):
|
||||
Position = apps.get_model('mapdata', 'position')
|
||||
for position in Position.objects.all():
|
||||
position.short_name = position.name[:2]
|
||||
position.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mapdata', '0136_wifi_bssids_to_addresses_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='position',
|
||||
name='short_name',
|
||||
field=models.CharField(help_text='two characters maximum', max_length=2, null=True, verbose_name='abbreviation'),
|
||||
),
|
||||
migrations.RunPython(generate_short_name, migrations.RunPython.noop),
|
||||
migrations.AlterField(
|
||||
model_name='position',
|
||||
name='short_name',
|
||||
field=models.CharField(help_text='two characters maximum', max_length=2, verbose_name='abbreviation'),
|
||||
),
|
||||
]
|
|
@ -444,7 +444,7 @@ class BeaconMeasurement(SpaceGeometryMixin, models.Model):
|
|||
verbose_name=_('author'))
|
||||
comment = models.TextField(null=True, blank=True, verbose_name=_('comment'))
|
||||
data: BeaconMeasurementDataSchema = SchemaField(BeaconMeasurementDataSchema,
|
||||
verbose_name=_('Measurement list'),
|
||||
verbose_name=_('Measurement list'),
|
||||
default=BeaconMeasurementDataSchema())
|
||||
|
||||
fill_quest = models.BooleanField(_('create a quest to fill this'), default=False)
|
||||
|
@ -462,17 +462,42 @@ class BeaconMeasurement(SpaceGeometryMixin, models.Model):
|
|||
def geometry_changed(self):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def contribute_bssid_to_beacons(items: list["BeaconMeasurement"]):
|
||||
map_name = {}
|
||||
for item in items:
|
||||
for scan in item.data.wifi:
|
||||
for peer in scan:
|
||||
if peer.ap_name:
|
||||
map_name.setdefault(peer.ap_name, []).append(peer.bssid)
|
||||
for beacon in RangingBeacon.objects.filter(ap_name__in=map_name.keys(),
|
||||
beacon_type=RangingBeacon.BeaconType.EVENT_WIFI):
|
||||
print(beacon, "add ssids", set(map_name[beacon.ap_name]))
|
||||
beacon.addresses = list(set(beacon.addresses) | set(map_name[beacon.ap_name]))
|
||||
beacon.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.contribute_bssid_to_beacons([self])
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class RangingBeacon(SpaceGeometryMixin, models.Model):
|
||||
"""
|
||||
A ranging beacon
|
||||
"""
|
||||
class BeaconType(models.TextChoices):
|
||||
EVENT_WIFI = "event_wifi", _("Event WiFi AP")
|
||||
DECT = "dect", _("DECT antenna")
|
||||
|
||||
geometry = GeometryField('point')
|
||||
|
||||
beacon_type = models.CharField(_('beacon type'), choices=BeaconType.choices,
|
||||
null=True, blank=True, max_length=16)
|
||||
|
||||
node_number = models.PositiveSmallIntegerField(_('Node Number'), unique=True, null=True, blank=True)
|
||||
|
||||
wifi_bssids: list[MacAddress] = SchemaField(list[MacAddress], verbose_name=_('WiFi BSSIDs'), default=list,
|
||||
help_text=_("uses node's value if not set"))
|
||||
addresses: list[MacAddress] = SchemaField(list[MacAddress], verbose_name=_('Mac Address / BSSIDs'), default=list,
|
||||
help_text=_("uses node's value if not set"))
|
||||
bluetooth_address = models.CharField(_('Bluetooth Address'), unique=True, null=True, blank=True,
|
||||
max_length=17,
|
||||
validators=[RegexValidator(
|
||||
|
@ -498,6 +523,7 @@ class RangingBeacon(SpaceGeometryMixin, models.Model):
|
|||
|
||||
altitude = models.DecimalField(_('altitude above ground'), max_digits=6, decimal_places=2, default=0,
|
||||
validators=[MinValueValidator(Decimal('0'))])
|
||||
ap_name = models.CharField(null=True, blank=True, verbose_name=_('AP name'), max_length=32)
|
||||
comment = models.TextField(null=True, blank=True, verbose_name=_('comment'))
|
||||
|
||||
altitude_quest = models.BooleanField(_('altitude quest'), default=True)
|
||||
|
@ -517,10 +543,19 @@ class RangingBeacon(SpaceGeometryMixin, models.Model):
|
|||
|
||||
@property
|
||||
def title(self):
|
||||
if self.node_number is not None or self.wifi_bssids:
|
||||
if self.comment:
|
||||
return f'{self.node_number or ''} {''.join(self.wifi_bssids[:1])} ({self.comment})'.strip()
|
||||
else:
|
||||
return f'{self.node_number or ''} {''.join(self.wifi_bssids[:1])}'.strip()
|
||||
segments = []
|
||||
if self.node_number is not None:
|
||||
segments.append(self.node_number)
|
||||
if self.ap_name is not None:
|
||||
segments.append(f'"{self.ap_name}"')
|
||||
if segments:
|
||||
title = ' - '.join(segments).strip()
|
||||
else:
|
||||
return self.comment
|
||||
title = f'#{self.pk}'
|
||||
if self.addresses:
|
||||
ssids = self.addresses[0] + (', …' if len(self.addresses) > 1 else '')
|
||||
title += f' ({ssids})'
|
||||
if self.comment:
|
||||
title += f' ({self.comment})'
|
||||
|
||||
return f'{self.get_beacon_type_display() if self.beacon_type else self._meta.verbose_name} {title}'
|
||||
|
|
|
@ -587,6 +587,7 @@ class Position(CustomLocationProxyMixin, models.Model):
|
|||
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
name = models.CharField(_('name'), max_length=32)
|
||||
short_name = models.CharField(_('abbreviation'), help_text=_('two characters maximum'), max_length=2)
|
||||
secret = models.CharField(_('secret'), unique=True, max_length=32, default=get_position_secret)
|
||||
last_coordinates_update = models.DateTimeField(_('last coordinates update'), null=True)
|
||||
timeout = models.PositiveSmallIntegerField(_('timeout (in seconds)'), default=0, blank=True,
|
||||
|
@ -639,8 +640,9 @@ class Position(CustomLocationProxyMixin, models.Model):
|
|||
'title': self.name,
|
||||
'subtitle': _('currently unavailable'),
|
||||
}
|
||||
from c3nav.mapdata.schemas.models import CustomLocationSchema
|
||||
result = CustomLocationSchema.model_validate(custom_location).model_dump()
|
||||
# todo: is this good?
|
||||
from c3nav.mapdata.schemas.models import CustomLocationLocationSchema
|
||||
result = CustomLocationLocationSchema.model_validate(custom_location).model_dump()
|
||||
result.update({
|
||||
'available': True,
|
||||
'id': 'm:%s' % self.secret,
|
||||
|
|
|
@ -14,7 +14,7 @@ from django.utils.timezone import make_naive
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
from shapely.ops import unary_union
|
||||
|
||||
from c3nav.mapdata.tasks import process_map_updates
|
||||
from c3nav.mapdata.tasks import process_map_updates, delete_map_cache_key
|
||||
from c3nav.mapdata.utils.cache.changes import GeometryChangeTracker
|
||||
from c3nav.mapdata.utils.cache.local import per_request_cache
|
||||
|
||||
|
@ -193,6 +193,14 @@ class MapUpdate(models.Model):
|
|||
logger = logging.getLogger('c3nav')
|
||||
|
||||
with cls.get_updates_to_process() as new_updates:
|
||||
prev_keys = (
|
||||
cls.current_processed_cache_key(),
|
||||
cls.current_processed_geometry_cache_key(),
|
||||
)
|
||||
|
||||
for key in prev_keys:
|
||||
transaction.on_commit(lambda: delete_map_cache_key.delay(cache_key=key))
|
||||
|
||||
if not new_updates:
|
||||
return ()
|
||||
|
||||
|
@ -274,6 +282,10 @@ class MapUpdate(models.Model):
|
|||
if self.geometries_changed is None:
|
||||
self.geometries_changed = not changed_geometries.is_empty
|
||||
|
||||
old_cache_key = None
|
||||
if new:
|
||||
old_cache_key = self.current_cache_key()
|
||||
|
||||
super().save(**kwargs)
|
||||
|
||||
with suppress(FileExistsError):
|
||||
|
@ -283,6 +295,7 @@ class MapUpdate(models.Model):
|
|||
pickle.dump(changed_geometries, open(self._changed_geometries_filename(), 'wb'))
|
||||
|
||||
if new:
|
||||
transaction.on_commit(lambda: delete_map_cache_key.delay(cache_key=old_cache_key))
|
||||
transaction.on_commit(
|
||||
lambda: per_request_cache.set('mapdata:last_update', self.to_tuple, None)
|
||||
)
|
||||
|
|
|
@ -15,13 +15,13 @@ class RangingBeaconAltitudeQuestForm(ChangeSetModelForm):
|
|||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["altitude"].label = (
|
||||
_('How many meters above ground is the access point “%s” mounted?') % self.instance.comment
|
||||
_('How many meters above ground is “%s” mounted?') % self.instance.title
|
||||
)
|
||||
|
||||
def clean_altitude(self):
|
||||
data = self.cleaned_data["altitude"]
|
||||
if not data:
|
||||
raise ValidationError(_("The AP should not be 0m above ground."))
|
||||
raise ValidationError(_("The device should not be 0m above ground."))
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
|
@ -59,19 +59,18 @@ class RangingBeaconAltitudeQuest(Quest):
|
|||
class RangingBeaconBSSIDsQuestForm(ChangeSetModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["look_for_ap"] = CharField(disabled=True, initial=self.instance.import_tag[4:],
|
||||
widget=HiddenInput())
|
||||
self.fields["wifi_bssids"].widget = HiddenInput()
|
||||
self.fields["look_for_ap"] = CharField(disabled=True, initial=self.instance.ap_name, widget=HiddenInput())
|
||||
self.fields["addresses"].widget = HiddenInput()
|
||||
|
||||
def clean_bssids(self):
|
||||
data = self.cleaned_data["wifi_bssids"]
|
||||
def clean_addresses(self):
|
||||
data = self.cleaned_data["addresses"]
|
||||
if not data:
|
||||
raise ValidationError(_("Need at least one bssid."))
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = RangingBeacon
|
||||
fields = ("wifi_bssids", )
|
||||
fields = ("addresses", )
|
||||
|
||||
@property
|
||||
def changeset_title(self):
|
||||
|
@ -103,15 +102,17 @@ class RangingBeaconBSSIDsQuest(Quest):
|
|||
|
||||
@classmethod
|
||||
def _qs_for_request(cls, request):
|
||||
return RangingBeacon.qs_for_request(request).filter(import_tag__startswith="noc:", wifi_bssids=[])
|
||||
return RangingBeacon.qs_for_request(request).filter(ap_name__isnull=False, addresses=[],
|
||||
beacon_type=RangingBeacon.BeaconType.EVENT_WIFI)
|
||||
|
||||
|
||||
class BeaconMeasurementQuestForm(ChangeSetModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["beacon_measurement_quest"] = CharField(disabled=True, initial='', widget=HiddenInput(), required=False)
|
||||
self.fields["data"].widget = HiddenInput()
|
||||
|
||||
def clean_bssids(self):
|
||||
def clean_data(self):
|
||||
data = self.cleaned_data["data"]
|
||||
if not data:
|
||||
raise ValidationError(_("Need at least one scan."))
|
||||
|
|
|
@ -601,6 +601,10 @@ class TrackablePositionSchema(BaseSchema):
|
|||
description="slug representing the position",
|
||||
example="p:adskjfalskdj",
|
||||
)
|
||||
effective_slug: PositionID = APIField(
|
||||
description="slug representing the position",
|
||||
example="p:adskjfalskdj",
|
||||
)
|
||||
icon: Optional[NonEmptyStr] = APIField( # todo: not optional?
|
||||
title="set icon name",
|
||||
description="icon as set in the location specifically (any material design icon name)",
|
||||
|
|
|
@ -2,6 +2,7 @@ import logging
|
|||
import time
|
||||
|
||||
from celery.exceptions import MaxRetriesExceededError
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
@ -47,3 +48,37 @@ def process_map_updates(self):
|
|||
'date': date_format(updates[-1].datetime, 'DATETIME_FORMAT'),
|
||||
'id': updates[-1].pk,
|
||||
})
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=10)
|
||||
def delete_map_cache_key(self, cache_key):
|
||||
if hasattr(cache, 'keys'):
|
||||
for key in cache.keys(f'*{cache_key}*'):
|
||||
cache.delete(key)
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=10)
|
||||
def update_ap_names_bssid_mapping(self, map_name, user_id):
|
||||
user = get_user_model().objects.filter(pk=user_id).first()
|
||||
if user is None:
|
||||
return
|
||||
from c3nav.mapdata.models.geometry.space import RangingBeacon
|
||||
todo = []
|
||||
for beacon in RangingBeacon.objects.filter(ap_name__in=map_name.keys(),
|
||||
beacon_type=RangingBeacon.BeaconType.EVENT_WIFI):
|
||||
print(beacon, "add ssids", set(map_name[beacon.ap_name]))
|
||||
if set(map_name[beacon.ap_name]) - set(beacon.addresses):
|
||||
todo.append((beacon, list(set(beacon.addresses) | set(map_name[beacon.ap_name]))))
|
||||
|
||||
if todo:
|
||||
from c3nav.editor.models import ChangeSet
|
||||
from c3nav.editor.views.base import within_changeset
|
||||
changeset = ChangeSet()
|
||||
changeset.author = user
|
||||
with within_changeset(changeset=changeset, user=user) as locked_changeset:
|
||||
for beacon, addresses in todo:
|
||||
beacon.addresses = addresses
|
||||
beacon.save()
|
||||
with changeset.lock_to_edit() as locked_changeset:
|
||||
locked_changeset.title = 'passive update bssids'
|
||||
locked_changeset.apply(user)
|
||||
|
|
|
@ -293,6 +293,10 @@ class CustomLocation:
|
|||
y = round(self.y, 2)
|
||||
self.pk = 'c:%s:%s:%s' % (self.level.level_index, x, y)
|
||||
|
||||
@property
|
||||
def rounded_pk(self):
|
||||
return 'c:%s:%s:%s' % (self.level.level_index, self.x//5*5, self.y//5*5)
|
||||
|
||||
@property
|
||||
def serialized_geometry(self):
|
||||
return {
|
||||
|
|
81
src/c3nav/mapdata/utils/placement.py
Normal file
81
src/c3nav/mapdata/utils/placement.py
Normal file
|
@ -0,0 +1,81 @@
|
|||
from typing import Optional
|
||||
|
||||
from shapely import Point, distance
|
||||
from shapely.ops import unary_union, nearest_points
|
||||
|
||||
from c3nav.mapdata.models import Level, Space
|
||||
from c3nav.mapdata.utils.geometry import unwrap_geom
|
||||
from c3nav.routing.router import RouterRestrictionSet
|
||||
|
||||
|
||||
class PointPlacementHelper:
|
||||
def __init__(self):
|
||||
self.spaces_for_level = {}
|
||||
self.levels = tuple(Level.objects.values_list("pk", flat=True))
|
||||
self.lower_levels_for_level = {pk: self.levels[:i] for i, pk in enumerate(self.levels)}
|
||||
|
||||
for space in Space.objects.select_related('level').prefetch_related('holes'):
|
||||
self.spaces_for_level.setdefault(space.level_id, []).append(space)
|
||||
|
||||
def get_point_and_space(self, level_id: int, point: Point, name: Optional[str] = None,
|
||||
restrictions: Optional[RouterRestrictionSet] = None, max_space_distance=1.5):
|
||||
# determine space
|
||||
restricted_spaces = restrictions.spaces if restrictions else ()
|
||||
possible_spaces = [space for space in self.spaces_for_level[level_id]
|
||||
if space.pk not in restricted_spaces and space.geometry.intersects(point)]
|
||||
|
||||
if not possible_spaces:
|
||||
possible_spaces = [space for space in self.spaces_for_level[level_id]
|
||||
if (space.pk not in restricted_spaces
|
||||
and distance(unwrap_geom(space.geometry), point) < max_space_distance)]
|
||||
if len(possible_spaces) == 1:
|
||||
new_space = possible_spaces[0]
|
||||
the_distance = distance(unwrap_geom(new_space.geometry), point)
|
||||
if name:
|
||||
print(f"SUCCESS: {name} is {the_distance:.02f}m away from {new_space.title}")
|
||||
elif len(possible_spaces) > 1:
|
||||
new_space = min(possible_spaces, key=lambda s: distance(unwrap_geom(s.geometry), point))
|
||||
if name:
|
||||
print(f"WARNING: {name} could be in multiple spaces ({possible_spaces}, picking {new_space}, "
|
||||
f"which is {distance(unwrap_geom(new_space.geometry), point)}m away...")
|
||||
else:
|
||||
if name:
|
||||
print(f"ERROR: {name} is not within any space on level {level_id} ({point})")
|
||||
return None, None
|
||||
|
||||
# move point into space if needed
|
||||
new_space_geometry = new_space.geometry.difference(
|
||||
unary_union([unwrap_geom(hole.geometry) for hole in new_space.columns.all()])
|
||||
)
|
||||
if not new_space_geometry.intersects(point):
|
||||
point = nearest_points(new_space_geometry.buffer(-0.05), point)[0]
|
||||
elif len(possible_spaces) == 1:
|
||||
new_space = possible_spaces[0]
|
||||
if name:
|
||||
print(f"SUCCESS: {name} is in {new_space.title}")
|
||||
else:
|
||||
if name:
|
||||
print(f"WARNING: {name} could be in multiple spaces, picking one...")
|
||||
new_space = possible_spaces[0]
|
||||
|
||||
lower_levels = self.lower_levels_for_level[new_space.level_id]
|
||||
for lower_level in reversed(lower_levels):
|
||||
# let's go through the lower levels
|
||||
if not unary_union([unwrap_geom(h.geometry) for h in new_space.holes.all()]).intersects(point):
|
||||
# current selected spacae is fine, that's it
|
||||
break
|
||||
if name:
|
||||
print(f"NOTE: {name} is in a hole, looking lower...")
|
||||
|
||||
# find a lower space
|
||||
possible_spaces = [space for space in self.spaces_for_level[lower_level]
|
||||
if space.pk not in restricted_spaces and space.geometry.intersects(point)]
|
||||
if possible_spaces:
|
||||
new_space = possible_spaces[0]
|
||||
if name:
|
||||
print(f"NOTE: {name} moved to lower space {new_space}")
|
||||
else:
|
||||
if name:
|
||||
print(f"WARNING: {name} couldn't find a lower space, still in a hole")
|
||||
|
||||
return new_space, point
|
|
@ -272,7 +272,7 @@ def mesh_map(request, level_id: int):
|
|||
"geometry": mapping(beacon.geometry),
|
||||
"properties": {
|
||||
"node_number": beacon.node_number,
|
||||
"wifi_bssid": (beacon.wifi_bssids + [None])[0],
|
||||
"wifi_bssid": (beacon.addresses + [None])[0],
|
||||
"comment": beacon.comment,
|
||||
"mesh_node": None if node is None else {
|
||||
"address": node.address,
|
||||
|
|
|
@ -197,10 +197,10 @@ class MeshNodeQuerySet(models.QuerySet):
|
|||
for ranging_beacon in RangingBeacon.objects.filter(
|
||||
Q(node_number__in=nodes_by_id.keys())
|
||||
).select_related('space'):
|
||||
if not (set(ranging_beacon.wifi_bssids) & nodes_by_bssid_keys):
|
||||
if not (set(ranging_beacon.addresses) & nodes_by_bssid_keys):
|
||||
continue
|
||||
# noinspection PyUnresolvedReferences
|
||||
for bssid in ranging_beacon.wifi_bssids:
|
||||
for bssid in ranging_beacon.addresses:
|
||||
with suppress(KeyError):
|
||||
nodes_by_bssid[bssid]._ranging_beacon = ranging_beacon
|
||||
with suppress(KeyError):
|
||||
|
|
|
@ -60,8 +60,8 @@ def get_nodes_and_ranging_beacons():
|
|||
ranging_beacon = beacons[ranging_beacon_id]
|
||||
ranging_beacon.save = None
|
||||
|
||||
if not ranging_beacon.wifi_bssids:
|
||||
ranging_beacon.wifi_bssids = [node.address]
|
||||
if not ranging_beacon.addresses:
|
||||
ranging_beacon.addresses = [node.address]
|
||||
if not ranging_beacon.bluetooth_address:
|
||||
ranging_beacon.bluetooth_address = node.address[:-2] + hex(int(node.address[-2:], 16)+1)[2:]
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@ from c3nav.api.auth import auth_responses
|
|||
from c3nav.api.schema import BaseSchema
|
||||
from c3nav.mapdata.models.access import AccessPermission
|
||||
from c3nav.mapdata.schemas.models import CustomLocationSchema
|
||||
from c3nav.mapdata.tasks import update_ap_names_bssid_mapping
|
||||
from c3nav.mapdata.utils.cache.stats import increment_cache_key
|
||||
from c3nav.routing.locator import Locator
|
||||
from c3nav.routing.schemas import LocateWifiPeerSchema, LocateIBeaconPeerSchema
|
||||
|
@ -45,12 +46,23 @@ def get_position(request, parameters: LocateRequestSchema):
|
|||
location = Locator.load().locate(parameters.wifi_peers,
|
||||
permissions=AccessPermission.get_for_request(request))
|
||||
if location is not None:
|
||||
# todo: this will overload us probably, group these
|
||||
increment_cache_key('apistats__locate__%s' % location.pk)
|
||||
increment_cache_key('apistats__locate__%s' % location.rounded_pk)
|
||||
except ValidationError:
|
||||
# todo: validation error, seriously? this shouldn't happen anyways
|
||||
raise
|
||||
|
||||
if request.user_permissions.passive_ap_name_scanning:
|
||||
bssid_mapping = {}
|
||||
for peer in parameters.wifi_peers:
|
||||
if not peer.ap_name:
|
||||
continue
|
||||
bssid_mapping.setdefault(peer.ap_name, set()).add(peer.bssid)
|
||||
if bssid_mapping:
|
||||
update_ap_names_bssid_mapping.delay(
|
||||
map_name={str(name): [str(b) for b in bssids] for name, bssids in bssid_mapping.items()},
|
||||
user_id=request.user.pk
|
||||
)
|
||||
|
||||
return {
|
||||
"location": location
|
||||
}
|
||||
|
|
|
@ -140,7 +140,7 @@ class RouteLevelSchema(DjangoModelSchema):
|
|||
)
|
||||
|
||||
class RouteItemSchema(BaseSchema):
|
||||
id: PositiveInt
|
||||
id: Optional[PositiveInt]
|
||||
coordinates: Coordinates3D
|
||||
waytype: Union[
|
||||
Annotated[ShortWayTypeSchema, APIField(title="waytype", descripiton="waytype used for this segment")],
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import operator
|
||||
import pickle
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from functools import cached_property, reduce
|
||||
from pprint import pprint
|
||||
from typing import Annotated
|
||||
from typing import Annotated, NamedTuple, Union
|
||||
from typing import Optional, Self, Sequence, TypeAlias
|
||||
from uuid import UUID
|
||||
|
||||
|
@ -12,9 +12,11 @@ from annotated_types import Lt
|
|||
from django.conf import settings
|
||||
from pydantic.types import NonNegativeInt
|
||||
from pydantic_extra_types.mac_address import MacAddress
|
||||
from shapely import Point
|
||||
|
||||
from c3nav.mapdata.models import MapUpdate, Space
|
||||
from c3nav.mapdata.utils.locations import CustomLocation
|
||||
from c3nav.mapdata.utils.placement import PointPlacementHelper
|
||||
from c3nav.mesh.utils import get_nodes_and_ranging_beacons
|
||||
from c3nav.routing.router import Router
|
||||
from c3nav.routing.schemas import LocateWifiPeerSchema, BeaconMeasurementDataSchema, LocateIBeaconPeerSchema
|
||||
|
@ -24,14 +26,28 @@ try:
|
|||
except ImportError:
|
||||
from threading import local as LocalContext
|
||||
|
||||
LocatorPeerIdentifier: TypeAlias = MacAddress | tuple[UUID, Annotated[NonNegativeInt, Lt(2 ** 16)], Annotated[NonNegativeInt, Lt(2 ** 16)]]
|
||||
|
||||
class PeerType(StrEnum):
|
||||
WIFI = "wifi"
|
||||
DECT = "dect"
|
||||
IBEACON = "ibeacon"
|
||||
|
||||
|
||||
class TypedIdentifier(NamedTuple):
|
||||
peer_type: PeerType
|
||||
identifier: Union[
|
||||
MacAddress,
|
||||
str,
|
||||
tuple[UUID, Annotated[NonNegativeInt, Lt(2 ** 16)], Annotated[NonNegativeInt, Lt(2 ** 16)]]
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocatorPeer:
|
||||
identifier: LocatorPeerIdentifier
|
||||
identifier: TypedIdentifier
|
||||
frequencies: set[int] = field(default_factory=set)
|
||||
xyz: Optional[tuple[int, int, int]] = None
|
||||
space_id: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -65,9 +81,10 @@ class LocatorPoint:
|
|||
@dataclass
|
||||
class Locator:
|
||||
peers: list[LocatorPeer] = field(default_factory=list)
|
||||
peer_lookup: dict[LocatorPeerIdentifier, int] = field(default_factory=dict)
|
||||
peer_lookup: dict[TypedIdentifier, int] = field(default_factory=dict)
|
||||
xyz: np.array = field(default_factory=(lambda: np.empty((0,))))
|
||||
spaces: dict[int, "LocatorSpace"] = field(default_factory=dict)
|
||||
placement_helper: Optional[PointPlacementHelper] = None
|
||||
|
||||
@classmethod
|
||||
def rebuild(cls, update, router):
|
||||
|
@ -80,10 +97,14 @@ class Locator:
|
|||
calculated = get_nodes_and_ranging_beacons()
|
||||
for beacon in calculated.beacons.values():
|
||||
identifiers = []
|
||||
for bssid in beacon.wifi_bssids:
|
||||
identifiers.append(bssid)
|
||||
for bssid in beacon.addresses:
|
||||
identifiers.append(TypedIdentifier(PeerType.WIFI, bssid.lower()))
|
||||
if beacon.ap_name:
|
||||
identifiers.append(TypedIdentifier(PeerType.WIFI, beacon.ap_name))
|
||||
if beacon.ibeacon_uuid and beacon.ibeacon_major is not None and beacon.ibeacon_minor is not None:
|
||||
identifiers.append((beacon.ibeacon_uuid, beacon.ibeacon_major, beacon.ibeacon_minor))
|
||||
identifiers.append(
|
||||
TypedIdentifier(PeerType.IBEACON, (beacon.ibeacon_uuid, beacon.ibeacon_major, beacon.ibeacon_minor))
|
||||
)
|
||||
for identifier in identifiers:
|
||||
peer_id = self.get_peer_id(identifier, create=True)
|
||||
self.peers[peer_id].xyz = (
|
||||
|
@ -91,6 +112,7 @@ class Locator:
|
|||
int(beacon.geometry.y * 100),
|
||||
int((router.altitude_for_point(beacon.space_id, beacon.geometry) + float(beacon.altitude)) * 100),
|
||||
)
|
||||
self.peers[peer_id].space_id = beacon.space_id
|
||||
self.xyz = np.array(tuple(peer.xyz for peer in self.peers))
|
||||
|
||||
for space in Space.objects.prefetch_related('beacon_measurements'):
|
||||
|
@ -108,7 +130,9 @@ class Locator:
|
|||
if new_space.points:
|
||||
self.spaces[space.pk] = new_space
|
||||
|
||||
def get_peer_id(self, identifier: LocatorPeerIdentifier, create=False) -> Optional[int]:
|
||||
self.placement_helper = PointPlacementHelper()
|
||||
|
||||
def get_peer_id(self, identifier: TypedIdentifier, create=False) -> Optional[int]:
|
||||
peer_id = self.peer_lookup.get(identifier, None)
|
||||
if peer_id is None and create:
|
||||
peer = LocatorPeer(identifier=identifier)
|
||||
|
@ -122,8 +146,11 @@ class Locator:
|
|||
for scan_value in scan_data:
|
||||
if settings.WIFI_SSIDS and scan_value.ssid not in settings.WIFI_SSIDS:
|
||||
continue
|
||||
peer_id = self.get_peer_id(scan_value.bssid, create=create_peers)
|
||||
if peer_id is not None:
|
||||
peer_ids = {
|
||||
self.get_peer_id(TypedIdentifier(PeerType.WIFI, scan_value.bssid.lower()), create=create_peers),
|
||||
self.get_peer_id(TypedIdentifier(PeerType.WIFI, scan_value.ap_name), create=create_peers),
|
||||
} - {None, ""}
|
||||
for peer_id in peer_ids:
|
||||
result[peer_id] = ScanDataValue(rssi=scan_value.rssi, distance=scan_value.distance)
|
||||
return result
|
||||
|
||||
|
@ -131,7 +158,7 @@ class Locator:
|
|||
result = {}
|
||||
for scan_value in scan_data:
|
||||
peer_id = self.get_peer_id(
|
||||
(scan_value.uuid, scan_value.major, scan_value.minor),
|
||||
TypedIdentifier(PeerType.IBEACON, (scan_value.uuid, scan_value.major, scan_value.minor)),
|
||||
create=create_peers
|
||||
)
|
||||
if peer_id is not None:
|
||||
|
@ -179,13 +206,13 @@ class Locator:
|
|||
def convert_raw_scan_data(self, raw_scan_data: list[LocateWifiPeerSchema]) -> ScanData:
|
||||
return self.convert_wifi_scan(raw_scan_data, create_peers=False)
|
||||
|
||||
def get_xyz(self, identifier: LocatorPeerIdentifier) -> tuple[int, int, int] | None:
|
||||
def get_xyz(self, identifier: TypedIdentifier) -> tuple[int, int, int] | None:
|
||||
i = self.get_peer_id(identifier)
|
||||
if i is None:
|
||||
return None
|
||||
return self.peers[i].xyz
|
||||
|
||||
def get_all_nodes_xyz(self) -> dict[LocatorPeerIdentifier, tuple[float, float, float]]:
|
||||
def get_all_nodes_xyz(self) -> dict[TypedIdentifier, tuple[float, float, float]]:
|
||||
return {
|
||||
peer.identifier: peer.xyz for peer in self.peers[:len(self.xyz)]
|
||||
if isinstance(peer.identifier, MacAddress)
|
||||
|
@ -201,8 +228,74 @@ class Locator:
|
|||
if result is not None:
|
||||
return result
|
||||
|
||||
result = self.locate_by_beacon_positions(scan_data, permissions)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return self.locate_rssi(scan_data, permissions)
|
||||
|
||||
def locate_by_beacon_positions(self, scan_data: ScanData, permissions=None):
|
||||
scan_data_we_can_use = [
|
||||
(peer_id, value) for peer_id, value in scan_data.items()
|
||||
if self.peers[peer_id].space_id and -90 < value.rssi < -10
|
||||
]
|
||||
|
||||
if not scan_data_we_can_use:
|
||||
return None
|
||||
|
||||
router = Router.load()
|
||||
restrictions = router.get_restrictions(permissions)
|
||||
|
||||
# get visible spaces
|
||||
best_ap_id = max(scan_data_we_can_use, key=lambda item: item[1].rssi)[0]
|
||||
space_id = self.peers[best_ap_id].space_id
|
||||
space = router.spaces[space_id]
|
||||
|
||||
scan_data_in_the_same_room = sorted([
|
||||
(peer_id, value) for peer_id, value in scan_data_we_can_use if self.peers[peer_id].space_id == space_id
|
||||
], key=lambda a: -a[1].rssi)
|
||||
|
||||
deduplicized_scan_data_in_the_same_room = []
|
||||
already_got = set()
|
||||
for peer_id, value in scan_data_in_the_same_room:
|
||||
key = tuple(self.peers[peer_id].xyz)
|
||||
if key in already_got:
|
||||
continue
|
||||
already_got.add(key)
|
||||
deduplicized_scan_data_in_the_same_room.append((peer_id, value))
|
||||
|
||||
the_sum = sum((value.rssi + 90) for peer_id, value in deduplicized_scan_data_in_the_same_room[:3])
|
||||
|
||||
level = router.levels[space.level_id]
|
||||
if not the_sum:
|
||||
point = space.point
|
||||
else:
|
||||
x = 0
|
||||
y = 0
|
||||
# sure this can be better probably
|
||||
for peer_id, value in deduplicized_scan_data_in_the_same_room[:3]:
|
||||
x += float(self.peers[peer_id].xyz[0]) * (value.rssi+90) / the_sum
|
||||
y += float(self.peers[peer_id].xyz[1]) * (value.rssi+90) / the_sum
|
||||
point = Point(x/100, y/100)
|
||||
|
||||
new_space, new_point = self.placement_helper.get_point_and_space(
|
||||
level_id=level.pk, point=point,
|
||||
max_space_distance=20,
|
||||
)
|
||||
|
||||
if new_space is not None:
|
||||
level = router.levels[new_space.level_id]
|
||||
if level.on_top_of_id:
|
||||
level = router.levels[level.on_top_of_id]
|
||||
|
||||
return CustomLocation(
|
||||
level=level,
|
||||
x=point.x,
|
||||
y=point.y,
|
||||
permissions=permissions,
|
||||
icon='my_location'
|
||||
)
|
||||
|
||||
def locate_rssi(self, scan_data: ScanData, permissions=None):
|
||||
router = Router.load()
|
||||
restrictions = router.get_restrictions(permissions)
|
||||
|
@ -227,6 +320,9 @@ class Locator:
|
|||
if best_location is not None:
|
||||
best_location.score = best_score
|
||||
|
||||
if best_location is not None:
|
||||
return None
|
||||
|
||||
return best_location
|
||||
|
||||
@cached_property
|
||||
|
@ -305,14 +401,19 @@ class Locator:
|
|||
restrictions = router.get_restrictions(permissions)
|
||||
|
||||
result_pos = results.x
|
||||
|
||||
level = router.levels[router.level_id_for_xyz(
|
||||
(result_pos[0], result_pos[1], result_pos[2] - 1.3), # -1.3m cause we assume people to be above ground
|
||||
restrictions
|
||||
)]
|
||||
if level.on_top_of_id:
|
||||
level = router.levels[level.on_top_of_id]
|
||||
|
||||
location = CustomLocation(
|
||||
level=router.levels[router.level_id_for_xyz(
|
||||
(result_pos[0], result_pos[1], result_pos[2]-1.3), # -1.3m cause we assume people to be above ground
|
||||
restrictions
|
||||
)],
|
||||
level=level,
|
||||
x=result_pos[0]/100,
|
||||
y=result_pos[1]/100,
|
||||
permissions=(),
|
||||
permissions=permissions,
|
||||
icon='my_location'
|
||||
)
|
||||
location.z = result_pos[2]/100
|
||||
|
|
|
@ -223,6 +223,9 @@ if not HUB_API_SECRET:
|
|||
NOC_BASE = config.get('c3nav', 'noc_base', fallback='').removesuffix('/')
|
||||
NOC_LAYERS = NocLayersSchema.validate_json(config.get('c3nav', 'noc_layers', fallback='{}'))
|
||||
|
||||
POC_API_BASE = config.get('c3nav', 'poc_api_base', fallback='').removesuffix('/')
|
||||
POC_API_SECRET = config.get('c3nav', 'poc_api_secret', fallback='')
|
||||
|
||||
_db_backend = config.get('database', 'backend', fallback='sqlite3')
|
||||
DATABASES: dict[str, dict[str, str | int | Path]] = {
|
||||
'default': env.db_url('C3NAV_DATABASE') if 'C3NAV_DATABASE' in env else {
|
||||
|
|
|
@ -80,7 +80,7 @@ class ReportUpdateForm(ModelForm):
|
|||
class PositionForm(ModelForm):
|
||||
class Meta:
|
||||
model = Position
|
||||
fields = ['name', 'timeout']
|
||||
fields = ['name' ,"short_name", 'timeout']
|
||||
|
||||
|
||||
class PositionSetForm(Form):
|
||||
|
|
|
@ -2000,12 +2000,18 @@ blink {
|
|||
}
|
||||
}
|
||||
|
||||
.beacon-quest-scanner {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.ap-name-bssid-result {
|
||||
.ap-name-bssid-result, .beacon-quest-scanner > table {
|
||||
display: block;
|
||||
max-height: 30vh;
|
||||
overflow: scroll;
|
||||
border-radius: 4px;
|
||||
border: 1px solid gray;
|
||||
padding: 4px 0;
|
||||
box-shadow: inset 0px 0px 1px gray;
|
||||
box-shadow: inset 0 0 1px gray;
|
||||
|
||||
thead {
|
||||
border-bottom: 1px solid gray;
|
||||
|
|
|
@ -1314,9 +1314,30 @@ c3nav = {
|
|||
},
|
||||
_locationinput_click_suggestion: function () {
|
||||
const $locationinput = $('#' + c3nav.current_locationinput);
|
||||
c3nav._locationinput_set($locationinput, c3nav.locations_by_id[$(this).attr('data-id')]);
|
||||
c3nav.update_state();
|
||||
c3nav.fly_to_bounds(true);
|
||||
const $this = $(this);
|
||||
const locationId = $this.attr('data-id');
|
||||
if (locationId) {
|
||||
c3nav._locationinput_set($locationinput, c3nav.locations_by_id[$(this).attr('data-id')]);
|
||||
c3nav.update_state();
|
||||
c3nav.fly_to_bounds(true);
|
||||
} else {
|
||||
const overlayId = $this.attr('data-overlay-id');
|
||||
if (overlayId) {
|
||||
const featureId = $this.attr('data-feature-id');
|
||||
|
||||
const overlay = c3nav._overlayControl._overlays[overlayId];
|
||||
const featureLayer = overlay.feature_layers[featureId];
|
||||
const feature = overlay.features_by_id[featureId];
|
||||
const bounds = featureLayer.getBounds();
|
||||
c3nav.update_map_state(true, feature.level_id, bounds.getCenter(), c3nav.map.getZoom());
|
||||
c3nav._locationLayerBounds = {[feature.level_id]: bounds};
|
||||
c3nav.fly_to_bounds(true);
|
||||
featureLayer.fire('click');
|
||||
|
||||
c3nav._locationinput_clear();
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
_locationinput_matches_compare: function (a, b) {
|
||||
if (a[1] !== b[1]) return b[1] - a[1];
|
||||
|
@ -1387,6 +1408,11 @@ c3nav = {
|
|||
matches.push([location.elem, leading_words_count, words_total_count, words_start_count, -location.title.length, i])
|
||||
}
|
||||
|
||||
for (const overlay of c3nav.activeOverlays()) {
|
||||
matches.push(...overlay.search(val_words));
|
||||
}
|
||||
|
||||
|
||||
matches.sort(c3nav._locationinput_matches_compare);
|
||||
|
||||
$autocomplete.html('');
|
||||
|
@ -1471,22 +1497,115 @@ c3nav = {
|
|||
},
|
||||
_set_modal_content: function (content, no_close) {
|
||||
const $modal = $('#modal');
|
||||
const $content = $modal.find('#modal-content');
|
||||
$modal.toggleClass('loading', !content)
|
||||
.find('#modal-content')
|
||||
.html((!no_close) ? '<button class="button-clear material-symbols" id="close-modal">clear</button>' : '')
|
||||
$content.html((!no_close) ? '<button class="button-clear material-symbols" id="close-modal">clear</button>' : '')
|
||||
.append(content || '<div class="loader"></div>');
|
||||
if ($modal.find('[name=look_for_ap]').length) {
|
||||
if ($content.find('[name=look_for_ap]').length) {
|
||||
$content.find('button[type=submit]').hide();
|
||||
if (!window.mobileclient) {
|
||||
alert('need app!')
|
||||
$content.find('p, form').remove();
|
||||
$content.append('<p>This quest is only available in the android app.</p>'); // TODO translate
|
||||
} else {
|
||||
c3nav._ap_name_scan_result_update();
|
||||
}
|
||||
} else if ($content.find('[name=beacon_measurement_quest]').length) {
|
||||
$content.find('button[type=submit]').hide();
|
||||
if (!window.mobileclient) {
|
||||
$content.find('p, form').remove();
|
||||
$content.append('<p>This quest is only available in the android app.</p>'); // TODO translate
|
||||
} else {
|
||||
const $scanner = $('<div class="beacon-quest-scanner"></div>');
|
||||
const $button = $('<button class="button">start scanning</button>')
|
||||
.click(() => {
|
||||
$button.remove();
|
||||
$scanner.append('<p>Scanning… Please do not close this popup and do not move.</p>');
|
||||
c3nav._quest_wifi_scans = [];
|
||||
c3nav._beacon_quest_scanning = true;
|
||||
})
|
||||
$scanner.append($button);
|
||||
$content.find('form').prev().after($scanner)
|
||||
}
|
||||
$modal.find('button').hide();
|
||||
}
|
||||
},
|
||||
_quest_wifi_scans: [],
|
||||
_quest_ibeacon_scans: [],
|
||||
_wifi_measurement_scan_update: function () {
|
||||
|
||||
const wifi_display_results = [];
|
||||
const bluetooth_display_results = [];
|
||||
for (const scan of c3nav._quest_wifi_scans) {
|
||||
for (const peer of scan) {
|
||||
let found = false;
|
||||
for (const existing_peer of wifi_display_results) {
|
||||
if (peer.bssid === existing_peer.bssid && peer.ssid === existing_peer.ssid) {
|
||||
existing_peer.rssi = peer.rssi;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
wifi_display_results.push(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const scan of c3nav._quest_ibeacon_scans) {
|
||||
for (const peer of scan) {
|
||||
let found = false;
|
||||
for (const existing_peer of bluetooth_display_results) {
|
||||
if (peer.uuid === existing_peer.uuid && peer.major === existing_peer.major && peer.minor === existing_peer.minor) {
|
||||
existing_peer.distance = peer.distance;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
bluetooth_display_results.push(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const $scanner = $('#modal .beacon-quest-scanner');
|
||||
|
||||
const $wifi_table = $(`<table><tr><td colspan="3"><i>${c3nav._quest_wifi_scans.length} wifi scans</i></td></tr><tr><th>BSSID</th><th>SSID</th><th>RSSI</th></tr></table>`);
|
||||
|
||||
for (const peer of wifi_display_results) {
|
||||
$wifi_table.append(`<tr><td>${peer.bssid}</td><td>${peer.ssid}</td><td>${peer.rssi}</td></tr>`);
|
||||
}
|
||||
|
||||
|
||||
const $bluetooth_table = $(`<table><tr><td colspan="3"><i>${c3nav._quest_ibeacon_scans.length} wifi scans</i></td></tr><tr><th>ID</th><th>Distance</th></table>`);
|
||||
|
||||
for (const peer of bluetooth_display_results) {
|
||||
$bluetooth_table.append(`<tr><td>${peer.major}</td><td>${peer.minor}</td><td>${peer.distance}</td></tr>`);
|
||||
}
|
||||
|
||||
|
||||
$scanner.empty();
|
||||
if (c3nav._quest_wifi_scans.length < 1) {
|
||||
$scanner.append('<p>Scanning… Please do not close this popup and do not move.</p>');
|
||||
} else {
|
||||
$('#modal input[name=data]').val(JSON.stringify({
|
||||
wifi: c3nav._quest_wifi_scans,
|
||||
ibeacon: c3nav._quest_ibeacon_scans,
|
||||
}))
|
||||
$('#modal button[type=submit]').show();
|
||||
}
|
||||
|
||||
if (wifi_display_results.length > 0) {
|
||||
$scanner.append($wifi_table);
|
||||
}
|
||||
if (bluetooth_display_results.length > 0) {
|
||||
$scanner.append($bluetooth_table);
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
_ap_name_scan_result_update: function () {
|
||||
const $modal = $('#modal');
|
||||
const $match_ap = $modal.find('[name=look_for_ap]');
|
||||
if ($match_ap.length) {
|
||||
const $wifi_bssids = $('[name=wifi_bssids]');
|
||||
const $addresses = $('[name=addresses]');
|
||||
const ap_name = $match_ap.val();
|
||||
const found_bssids = {};
|
||||
let scan_complete = false;
|
||||
|
@ -1515,7 +1634,7 @@ c3nav = {
|
|||
|
||||
if (scan_complete) {
|
||||
// todo only bssids that have count > 1
|
||||
$wifi_bssids.val(JSON.stringify(Object.keys(found_bssids)));
|
||||
$addresses.val(JSON.stringify(Object.keys(found_bssids)));
|
||||
$('#modal button[type=submit]').show();
|
||||
}
|
||||
}
|
||||
|
@ -1630,6 +1749,7 @@ c3nav = {
|
|||
// setup level control
|
||||
c3nav._levelControl = new LevelControl({initialTheme: c3nav.theme}).addTo(c3nav.map);
|
||||
c3nav._locationLayers = {};
|
||||
c3nav._nearbyLayers = {};
|
||||
c3nav._locationLayerBounds = {};
|
||||
c3nav._detailLayers = {};
|
||||
c3nav._routeLayers = {};
|
||||
|
@ -1645,6 +1765,14 @@ c3nav = {
|
|||
const layerGroup = c3nav._levelControl.addLevel(level[0], level[2]);
|
||||
c3nav._detailLayers[level[0]] = L.layerGroup().addTo(layerGroup);
|
||||
c3nav._locationLayers[level[0]] = L.layerGroup().addTo(layerGroup);
|
||||
c3nav._nearbyLayers[level[0]] = L.markerClusterGroup({
|
||||
maxClusterRadius: 35,
|
||||
spiderLegPolylineOptions: {
|
||||
color: '#4b6c97',
|
||||
},
|
||||
showCoverageOnHover: false,
|
||||
iconCreateFunction: makeClusterIconCreate('#4b6c97'),
|
||||
}).addTo(layerGroup);
|
||||
c3nav._routeLayers[level[0]] = L.layerGroup().addTo(layerGroup);
|
||||
c3nav._userLocationLayers[level[0]] = L.layerGroup().addTo(layerGroup);
|
||||
c3nav._overlayLayers[level[0]] = L.layerGroup().addTo(layerGroup);
|
||||
|
@ -1870,25 +1998,31 @@ c3nav = {
|
|||
for (const level_id in c3nav._locationLayers) {
|
||||
c3nav._locationLayers[level_id].clearLayers()
|
||||
}
|
||||
for (const level_id in c3nav._nearbyLayers) {
|
||||
c3nav._nearbyLayers[level_id].clearLayers()
|
||||
}
|
||||
c3nav._visible_map_locations = [];
|
||||
if (origin) c3nav._merge_bounds(bounds, c3nav._add_location_to_map(origin, single ? c3nav.icons.default : c3nav.icons.origin));
|
||||
if (destination) c3nav._merge_bounds(bounds, c3nav._add_location_to_map(destination, single ? c3nav.icons.default : c3nav.icons.destination));
|
||||
const done = [];
|
||||
const done = new Set();
|
||||
if (c3nav.state.nearby && destination && 'areas' in destination) {
|
||||
if (destination.space) {
|
||||
c3nav._merge_bounds(bounds, c3nav._add_location_to_map(c3nav.locations_by_id[destination.space], c3nav.icons.nearby, true));
|
||||
done.add(destination.space);
|
||||
c3nav._merge_bounds(bounds, c3nav._add_location_to_map(c3nav.locations_by_id[destination.space], c3nav.icons.nearby, true, c3nav._nearbyLayers));
|
||||
}
|
||||
if (destination.near_area) {
|
||||
done.push(destination.near_area);
|
||||
c3nav._merge_bounds(bounds, c3nav._add_location_to_map(c3nav.locations_by_id[destination.near_area], c3nav.icons.nearby, true));
|
||||
done.add(destination.near_area);
|
||||
c3nav._merge_bounds(bounds, c3nav._add_location_to_map(c3nav.locations_by_id[destination.near_area], c3nav.icons.nearby, true, c3nav._nearbyLayers));
|
||||
}
|
||||
for (const area of destination.areas) {
|
||||
done.push(area);
|
||||
c3nav._merge_bounds(bounds, c3nav._add_location_to_map(c3nav.locations_by_id[area], c3nav.icons.nearby, true));
|
||||
if (done.has(area)) continue;
|
||||
done.add(area);
|
||||
c3nav._merge_bounds(bounds, c3nav._add_location_to_map(c3nav.locations_by_id[area], c3nav.icons.nearby, true, c3nav._nearbyLayers));
|
||||
}
|
||||
for (const location of destination.nearby) {
|
||||
if (location in done) continue;
|
||||
c3nav._merge_bounds(bounds, c3nav._add_location_to_map(c3nav.locations_by_id[location], c3nav.icons.nearby, true));
|
||||
if (done.has(location)) continue;
|
||||
done.add(destination.nearby);
|
||||
c3nav._merge_bounds(bounds, c3nav._add_location_to_map(c3nav.locations_by_id[location], c3nav.icons.nearby, true, c3nav._nearbyLayers));
|
||||
}
|
||||
}
|
||||
c3nav._locationLayerBounds = bounds;
|
||||
|
@ -1953,12 +2087,15 @@ c3nav = {
|
|||
];
|
||||
},
|
||||
_location_point_overrides: {},
|
||||
_add_location_to_map: function (location, icon, no_geometry) {
|
||||
_add_location_to_map: function (location, icon, no_geometry, layers) {
|
||||
if (!layers) {
|
||||
layers = c3nav._locationLayers;
|
||||
}
|
||||
if (!location) {
|
||||
// if location is not in the searchable list...
|
||||
return
|
||||
}
|
||||
if (location.dynamic || location.locationtype === "dynamiclocation") {
|
||||
if (location.dynamic || location.locationtype === "dynamiclocation" || location.locationtype === "position") {
|
||||
if (!('available' in location)) {
|
||||
c3nav_api.get(`map/positions/${location.id}/`)
|
||||
.then(c3nav._dynamic_location_loaded);
|
||||
|
@ -1996,7 +2133,7 @@ c3nav = {
|
|||
}).bindPopup(location.elem + buttons_html, c3nav._add_map_padding({
|
||||
className: 'location-popup',
|
||||
maxWidth: 500
|
||||
}, 'autoPanPaddingTopLeft', 'autoPanPaddingBottomRight')).addTo(c3nav._locationLayers[location.point[0]]);
|
||||
}, 'autoPanPaddingTopLeft', 'autoPanPaddingBottomRight')).addTo(layers[location.point[0]]);
|
||||
|
||||
const result = {};
|
||||
result[location.point[0]] = L.latLngBounds(
|
||||
|
@ -2116,6 +2253,11 @@ c3nav = {
|
|||
c3nav._overlayControl = control.addTo(c3nav.map);
|
||||
}
|
||||
},
|
||||
|
||||
activeOverlays: function () {
|
||||
return Object.values(c3nav._overlayControl._overlays).filter(o => o.active);
|
||||
},
|
||||
|
||||
_update_quests: function () {
|
||||
if (!c3nav.map) return;
|
||||
if (c3nav._questsControl) {
|
||||
|
@ -2171,6 +2313,7 @@ c3nav = {
|
|||
_last_ibeacon_peers: [],
|
||||
_no_scan_count: 0,
|
||||
_ap_name_mappings: {},
|
||||
_beacon_quest_scan_results: {},
|
||||
_enable_scan_debugging: false,
|
||||
_scan_debugging_results: [],
|
||||
_wifi_scan_results: function (peers) {
|
||||
|
@ -2214,12 +2357,23 @@ c3nav = {
|
|||
|
||||
c3nav._ap_name_scan_result_update();
|
||||
|
||||
if (c3nav._beacon_quest_scanning) {
|
||||
c3nav._quest_wifi_scans.push(peers);
|
||||
c3nav._wifi_measurement_scan_update();
|
||||
}
|
||||
|
||||
c3nav._last_wifi_peers = peers;
|
||||
c3nav._after_scan_results();
|
||||
},
|
||||
_ibeacon_scan_results: function (peers) {
|
||||
peers = JSON.parse(peers);
|
||||
c3nav._last_ibeacon_peers = peers;
|
||||
|
||||
if (c3nav._beacon_quest_scanning) {
|
||||
c3nav._quest_ibeacon_scans.push(peers);
|
||||
c3nav._wifi_measurement_scan_update();
|
||||
}
|
||||
|
||||
c3nav._after_scan_results();
|
||||
},
|
||||
_after_scan_results: function () {
|
||||
|
@ -2886,7 +3040,7 @@ QuestsControl = ExpandingControl.extend({
|
|||
.addTo(c3nav._questsLayers[quest.level_id])
|
||||
.on('click', function () {
|
||||
c3nav.open_modal();
|
||||
$.get(`/editor/quests/${quest_type}/${quest.identifier}`, c3nav._modal_loaded).fail(c3nav._modal_error);
|
||||
$.get(`/editor/quests/${quest_type}/${quest.identifier}/`, c3nav._modal_loaded).fail(c3nav._modal_error);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -3225,9 +3379,13 @@ L.SquareGridLayer = L.Layer.extend({
|
|||
|
||||
class DataOverlay {
|
||||
levels = null;
|
||||
features = [];
|
||||
features_by_id = {};
|
||||
feature_layers = {};
|
||||
feature_geometries = {};
|
||||
fetch_timeout = null;
|
||||
etag = null;
|
||||
active = false;
|
||||
|
||||
constructor(options) {
|
||||
this.id = options.id;
|
||||
|
@ -3251,6 +3409,7 @@ class DataOverlay {
|
|||
c3nav_api.get(`mapdata/dataoverlayfeaturegeometries/?overlay=${this.id}`)
|
||||
]);
|
||||
this.etag = etag;
|
||||
this.features = features;
|
||||
|
||||
this.feature_geometries = Object.fromEntries(feature_geometries.map(f => [f.id, f.geometry]));
|
||||
|
||||
|
@ -3293,7 +3452,11 @@ class DataOverlay {
|
|||
this.levels[id].clearLayers();
|
||||
}
|
||||
|
||||
this.feature_layers = {};
|
||||
this.features_by_id = {};
|
||||
|
||||
for (const feature of features) {
|
||||
this.features_by_id[feature.id] = feature;
|
||||
const geometry = this.feature_geometries[feature.id]
|
||||
const level_id = feature.level_id;
|
||||
if (!(level_id in this.levels)) {
|
||||
|
@ -3356,6 +3519,8 @@ class DataOverlay {
|
|||
}
|
||||
});
|
||||
|
||||
this.feature_layers[feature.id] = layer;
|
||||
|
||||
this.levels[level_id].addLayer(layer);
|
||||
}
|
||||
}
|
||||
|
@ -3372,9 +3537,11 @@ class DataOverlay {
|
|||
levels[id].addLayer(this.levels[id]);
|
||||
}
|
||||
}
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
disable(levels) {
|
||||
this.active = false;
|
||||
for (const id in levels) {
|
||||
if (id in this.levels) {
|
||||
levels[id].removeLayer(this.levels[id]);
|
||||
|
@ -3383,4 +3550,43 @@ class DataOverlay {
|
|||
window.clearTimeout(this.fetch_timeout);
|
||||
this.fetch_timeout = null;
|
||||
}
|
||||
|
||||
search(words) {
|
||||
const feature_matches = (feature, word) => {
|
||||
if (feature.title.toLowerCase().includes(word)) return true;
|
||||
for (const lang in feature.titles) {
|
||||
if (feature.titles[lang].toLowerCase().includes(word)) return true;
|
||||
}
|
||||
for (const key in feature.extra_data) {
|
||||
if (`${feature.extra_data[key]}`.toLowerCase().includes(word)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const matches = [];
|
||||
|
||||
for (const feature of this.features) {
|
||||
let nomatch = false;
|
||||
for (const word of words) {
|
||||
if (this.title.toLowerCase().includes(word)) continue;
|
||||
|
||||
if (!feature_matches(feature, word)) {
|
||||
nomatch = true;
|
||||
}
|
||||
}
|
||||
if (nomatch) continue;
|
||||
|
||||
const html = $('<div class="location">')
|
||||
.append($('<i class="icon material-symbols">').text(c3nav._map_material_icon(feature.point_icon ?? 'place')))
|
||||
.append($('<span>').text(feature.title))
|
||||
.append($('<small>').text(`${this.title} (Overlay)`))
|
||||
.attr('data-overlay-id', this.id)
|
||||
.attr('data-feature-id', feature.id);
|
||||
html.attr('data-location', JSON.stringify(location));
|
||||
|
||||
matches.push([html[0].outerHTML, 0, 0, 0, -feature.title.length, 0])
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
}
|
7
src/c3nav/static/leaflet/leaflet.js
vendored
7
src/c3nav/static/leaflet/leaflet.js
vendored
|
@ -7870,6 +7870,13 @@ var MultiPoint = Layer.extend({
|
|||
this._latlngs = this._convertLatLngs(latlngs);
|
||||
},
|
||||
|
||||
|
||||
|
||||
setLatLngs: function(latlngs) {
|
||||
this._setLatLngs(latlngs);
|
||||
},
|
||||
|
||||
|
||||
// convert latlngs input into actual LatLng instances; calculate bounds along the way
|
||||
_convertLatLngs: function (latlngs) {
|
||||
var result = [];
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue