Merge branch 'c3nav:main' into main

This commit is contained in:
Jenny Paxian 2024-12-29 17:43:22 +01:00 committed by GitHub
commit 174a5af5f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 959 additions and 163 deletions

View file

@ -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:

View file

@ -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'),
),
]

View file

@ -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')

View file

@ -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(

View file

@ -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',

View file

@ -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."

View file

@ -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()

View file

@ -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')

View file

@ -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():

View file

@ -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

View file

@ -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()

View 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")

View 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),
]

View file

@ -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),
]

View file

@ -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'),
),
]

View 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'),
),
]

View file

@ -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}'

View file

@ -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,

View file

@ -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)
)

View file

@ -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."))

View file

@ -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)",

View file

@ -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)

View file

@ -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 {

View 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

View file

@ -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,

View file

@ -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):

View file

@ -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:]

View file

@ -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
}

View file

@ -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")],

View file

@ -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

View file

@ -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 {

View file

@ -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):

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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 = [];