diff --git a/CHANGELOG.md b/CHANGELOG.md index bf141aee..e244b022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/src/c3nav/control/migrations/0019_userpermissions_passive_ap_name_scanning.py b/src/c3nav/control/migrations/0019_userpermissions_passive_ap_name_scanning.py new file mode 100644 index 00000000..2bfda63a --- /dev/null +++ b/src/c3nav/control/migrations/0019_userpermissions_passive_ap_name_scanning.py @@ -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'), + ), + ] diff --git a/src/c3nav/control/models.py b/src/c3nav/control/models.py index 856e25aa..9549d18c 100644 --- a/src/c3nav/control/models.py +++ b/src/c3nav/control/models.py @@ -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') diff --git a/src/c3nav/editor/api/endpoints.py b/src/c3nav/editor/api/endpoints.py index 75c11ca2..e9b95d3d 100644 --- a/src/c3nav/editor/api/endpoints.py +++ b/src/c3nav/editor/api/endpoints.py @@ -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( diff --git a/src/c3nav/editor/forms.py b/src/c3nav/editor/forms.py index 8d2fa519..8deafd06 100644 --- a/src/c3nav/editor/forms.py +++ b/src/c3nav/editor/forms.py @@ -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', diff --git a/src/c3nav/locale/de/LC_MESSAGES/django.po b/src/c3nav/locale/de/LC_MESSAGES/django.po index aaab6bef..3f888daa 100644 --- a/src/c3nav/locale/de/LC_MESSAGES/django.po +++ b/src/c3nav/locale/de/LC_MESSAGES/django.po @@ -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 \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." diff --git a/src/c3nav/mapdata/api/map.py b/src/c3nav/mapdata/api/map.py index da30b832..da993ae2 100644 --- a/src/c3nav/mapdata/api/map.py +++ b/src/c3nav/mapdata/api/map.py @@ -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() diff --git a/src/c3nav/mapdata/management/commands/bssid_from_scans_to_beacons.py b/src/c3nav/mapdata/management/commands/bssid_from_scans_to_beacons.py new file mode 100644 index 00000000..916033b5 --- /dev/null +++ b/src/c3nav/mapdata/management/commands/bssid_from_scans_to_beacons.py @@ -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') diff --git a/src/c3nav/mapdata/management/commands/findbeacons.py b/src/c3nav/mapdata/management/commands/findbeacons.py index 874316e8..2ffc0c38 100644 --- a/src/c3nav/mapdata/management/commands/findbeacons.py +++ b/src/c3nav/mapdata/management/commands/findbeacons.py @@ -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(): diff --git a/src/c3nav/mapdata/management/commands/importhub.py b/src/c3nav/mapdata/management/commands/importhub.py index 32e68e81..a8570756 100644 --- a/src/c3nav/mapdata/management/commands/importhub.py +++ b/src/c3nav/mapdata/management/commands/importhub.py @@ -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 diff --git a/src/c3nav/mapdata/management/commands/importnoc.py b/src/c3nav/mapdata/management/commands/importnoc.py index b1ee8b2f..2fbdfb4f 100644 --- a/src/c3nav/mapdata/management/commands/importnoc.py +++ b/src/c3nav/mapdata/management/commands/importnoc.py @@ -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() diff --git a/src/c3nav/mapdata/management/commands/importpoc.py b/src/c3nav/mapdata/management/commands/importpoc.py new file mode 100644 index 00000000..554c4527 --- /dev/null +++ b/src/c3nav/mapdata/management/commands/importpoc.py @@ -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") diff --git a/src/c3nav/mapdata/migrations/0134_rangingbeacon_ap_name.py b/src/c3nav/mapdata/migrations/0134_rangingbeacon_ap_name.py new file mode 100644 index 00000000..07dd6b2f --- /dev/null +++ b/src/c3nav/mapdata/migrations/0134_rangingbeacon_ap_name.py @@ -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), + ] diff --git a/src/c3nav/mapdata/migrations/0135_rangingbeacon_beacon_type.py b/src/c3nav/mapdata/migrations/0135_rangingbeacon_beacon_type.py new file mode 100644 index 00000000..95ff6c84 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0135_rangingbeacon_beacon_type.py @@ -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), + ] diff --git a/src/c3nav/mapdata/migrations/0136_wifi_bssids_to_addresses_and_more.py b/src/c3nav/mapdata/migrations/0136_wifi_bssids_to_addresses_and_more.py new file mode 100644 index 00000000..e38c88c6 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0136_wifi_bssids_to_addresses_and_more.py @@ -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'), + ), + ] diff --git a/src/c3nav/mapdata/migrations/0137_position_short_name.py b/src/c3nav/mapdata/migrations/0137_position_short_name.py new file mode 100644 index 00000000..faf062a7 --- /dev/null +++ b/src/c3nav/mapdata/migrations/0137_position_short_name.py @@ -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'), + ), + ] diff --git a/src/c3nav/mapdata/models/geometry/space.py b/src/c3nav/mapdata/models/geometry/space.py index f38ae720..a8d6d4f5 100644 --- a/src/c3nav/mapdata/models/geometry/space.py +++ b/src/c3nav/mapdata/models/geometry/space.py @@ -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}' diff --git a/src/c3nav/mapdata/models/locations.py b/src/c3nav/mapdata/models/locations.py index d109c082..8d8d79dd 100644 --- a/src/c3nav/mapdata/models/locations.py +++ b/src/c3nav/mapdata/models/locations.py @@ -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, diff --git a/src/c3nav/mapdata/models/update.py b/src/c3nav/mapdata/models/update.py index 4212a9b2..7841164b 100644 --- a/src/c3nav/mapdata/models/update.py +++ b/src/c3nav/mapdata/models/update.py @@ -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) ) diff --git a/src/c3nav/mapdata/quests/positioning.py b/src/c3nav/mapdata/quests/positioning.py index 1ef49903..f511fcf0 100644 --- a/src/c3nav/mapdata/quests/positioning.py +++ b/src/c3nav/mapdata/quests/positioning.py @@ -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.")) diff --git a/src/c3nav/mapdata/schemas/models.py b/src/c3nav/mapdata/schemas/models.py index ee9e87d1..ddc54116 100644 --- a/src/c3nav/mapdata/schemas/models.py +++ b/src/c3nav/mapdata/schemas/models.py @@ -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)", diff --git a/src/c3nav/mapdata/tasks.py b/src/c3nav/mapdata/tasks.py index 4d9c9e6c..2897c29a 100644 --- a/src/c3nav/mapdata/tasks.py +++ b/src/c3nav/mapdata/tasks.py @@ -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) diff --git a/src/c3nav/mapdata/utils/locations.py b/src/c3nav/mapdata/utils/locations.py index 596bd515..88c557ce 100644 --- a/src/c3nav/mapdata/utils/locations.py +++ b/src/c3nav/mapdata/utils/locations.py @@ -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 { diff --git a/src/c3nav/mapdata/utils/placement.py b/src/c3nav/mapdata/utils/placement.py new file mode 100644 index 00000000..e0c498ea --- /dev/null +++ b/src/c3nav/mapdata/utils/placement.py @@ -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 diff --git a/src/c3nav/mesh/api.py b/src/c3nav/mesh/api.py index 86a23076..47f591ea 100644 --- a/src/c3nav/mesh/api.py +++ b/src/c3nav/mesh/api.py @@ -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, diff --git a/src/c3nav/mesh/models.py b/src/c3nav/mesh/models.py index b7e8c33a..3dd084b6 100644 --- a/src/c3nav/mesh/models.py +++ b/src/c3nav/mesh/models.py @@ -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): diff --git a/src/c3nav/mesh/utils.py b/src/c3nav/mesh/utils.py index 0369822b..d2d48d90 100644 --- a/src/c3nav/mesh/utils.py +++ b/src/c3nav/mesh/utils.py @@ -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:] diff --git a/src/c3nav/routing/api/positioning.py b/src/c3nav/routing/api/positioning.py index 017a4283..85db46b5 100644 --- a/src/c3nav/routing/api/positioning.py +++ b/src/c3nav/routing/api/positioning.py @@ -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 } diff --git a/src/c3nav/routing/api/routing.py b/src/c3nav/routing/api/routing.py index 91075ed4..6d976a2c 100644 --- a/src/c3nav/routing/api/routing.py +++ b/src/c3nav/routing/api/routing.py @@ -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")], diff --git a/src/c3nav/routing/locator.py b/src/c3nav/routing/locator.py index ef058060..9a95f422 100644 --- a/src/c3nav/routing/locator.py +++ b/src/c3nav/routing/locator.py @@ -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 diff --git a/src/c3nav/settings.py b/src/c3nav/settings.py index 4724f18e..3cd38f07 100644 --- a/src/c3nav/settings.py +++ b/src/c3nav/settings.py @@ -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 { diff --git a/src/c3nav/site/forms.py b/src/c3nav/site/forms.py index fc51eeb2..1f98071f 100644 --- a/src/c3nav/site/forms.py +++ b/src/c3nav/site/forms.py @@ -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): diff --git a/src/c3nav/site/static/site/css/c3nav.scss b/src/c3nav/site/static/site/css/c3nav.scss index 050f79f6..8733d72d 100644 --- a/src/c3nav/site/static/site/css/c3nav.scss +++ b/src/c3nav/site/static/site/css/c3nav.scss @@ -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; diff --git a/src/c3nav/site/static/site/js/c3nav.js b/src/c3nav/site/static/site/js/c3nav.js index 47199afa..4e4a0ce3 100644 --- a/src/c3nav/site/static/site/js/c3nav.js +++ b/src/c3nav/site/static/site/js/c3nav.js @@ -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) ? '' : '') + $content.html((!no_close) ? '' : '') .append(content || '
'); - 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('

This quest is only available in the android app.

'); // 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('

This quest is only available in the android app.

'); // TODO translate + } else { + const $scanner = $('
'); + const $button = $('') + .click(() => { + $button.remove(); + $scanner.append('

Scanning… Please do not close this popup and do not move.

'); + 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 = $(`
${c3nav._quest_wifi_scans.length} wifi scans
BSSIDSSIDRSSI
`); + + for (const peer of wifi_display_results) { + $wifi_table.append(`${peer.bssid}${peer.ssid}${peer.rssi}`); + } + + + const $bluetooth_table = $(`
${c3nav._quest_ibeacon_scans.length} wifi scans
IDDistance
`); + + for (const peer of bluetooth_display_results) { + $bluetooth_table.append(`${peer.major}${peer.minor}${peer.distance}`); + } + + + $scanner.empty(); + if (c3nav._quest_wifi_scans.length < 1) { + $scanner.append('

Scanning… Please do not close this popup and do not move.

'); + } 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 = $('
') + .append($('').text(c3nav._map_material_icon(feature.point_icon ?? 'place'))) + .append($('').text(feature.title)) + .append($('').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; + } } \ No newline at end of file diff --git a/src/c3nav/static/leaflet/leaflet.js b/src/c3nav/static/leaflet/leaflet.js index 4b21ac32..a2fe2f49 100644 --- a/src/c3nav/static/leaflet/leaflet.js +++ b/src/c3nav/static/leaflet/leaflet.js @@ -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 = [];