diff --git a/src/c3nav/mapdata/management/commands/importpoc.py b/src/c3nav/mapdata/management/commands/importpoc.py new file mode 100644 index 00000000..b8c752b8 --- /dev/null +++ b/src/c3nav/mapdata/management/commands/importpoc.py @@ -0,0 +1,138 @@ +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.geometry.space import RangingBeacon +from c3nav.mapdata.models.report import Report +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): + """ + Something imported from the NOC + """ + lat: float | int + lng: float | int + layer: str + type: str = "unknown" + + +class Command(BaseCommand): + help = 'import APs from noc' + + def handle(self, *args, **options): + r = requests.get(settings.NOC_BASE+"/api/markers/get") + r.raise_for_status() + items = {name: NocImportItem.model_validate(item) + for name, item in r.json()["markers"].items() + if not name.startswith("__polyline")} + + with MapUpdate.lock(): + changed_geometries.reset() + self.do_import(items) + MapUpdate.objects.create(type='importnoc') + + 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) + + beacons_so_far: dict[str, RangingBeacon] = { + **{m.import_tag: m for m in RangingBeacon.objects.filter(import_tag__startswith="noc:")}, + } + + for name, item in items.items(): + import_tag = f"noc:{name}" + + if item.type != "AP": + continue + + # determine geometry + converter = settings.NOC_LAYERS.get(item.layer, None) + if not converter: + print(f"ERROR: {name} has invalid layer: {item.layer}") + continue + + new_geometry = 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 + + # 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(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") + + # 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) + else: + if result.space == new_space and distance(unwrap_geom(result.geometry), new_geometry) < 0.03: + continue + if result.space == new_space and distance(unwrap_geom(result.geometry), new_geometry) < 0.20: + altitude_quest = False + + result.comment = name + result.space = new_space + result.geometry = new_geometry + 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/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/models/geometry/space.py b/src/c3nav/mapdata/models/geometry/space.py index c8a6df3a..15a7c812 100644 --- a/src/c3nav/mapdata/models/geometry/space.py +++ b/src/c3nav/mapdata/models/geometry/space.py @@ -484,8 +484,15 @@ 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, @@ -535,11 +542,19 @@ class RangingBeacon(SpaceGeometryMixin, models.Model): @property def title(self): - if self.node_number is not None or self.wifi_bssids or self.ap_name: - if self.comment: - return (f'{self.node_number or ''} {''.join(self.wifi_bssids[:1])} {self.ap_name or ''} ' - f' ({self.comment})').strip() - else: - return f'{self.node_number or ''} {''.join(self.wifi_bssids[:1])} {self.ap_name or ''}'.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.wifi_bssids: + ssids = self.wifi_bssids[0] + (', …' if len(self.wifi_bssids) > 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/routing/locator.py b/src/c3nav/routing/locator.py index 2bf5f3a0..a25130e9 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 @@ -24,13 +24,25 @@ try: except ImportError: from threading import local as LocalContext -LocatorPeerIdentifier: TypeAlias = MacAddress | str | 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 @@ -67,7 +79,7 @@ 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) @@ -83,9 +95,11 @@ class Locator: for beacon in calculated.beacons.values(): identifiers = [] for bssid in beacon.wifi_bssids: - identifiers.append(bssid) + identifiers.append(TypedIdentifier(PeerType.WIFI, bssid)) 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 = ( @@ -111,7 +125,7 @@ class Locator: if new_space.points: self.spaces[space.pk] = new_space - def get_peer_id(self, identifier: LocatorPeerIdentifier, create=False) -> Optional[int]: + 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) @@ -126,9 +140,9 @@ class Locator: if settings.WIFI_SSIDS and scan_value.ssid not in settings.WIFI_SSIDS: continue peer_ids = { - self.get_peer_id(scan_value.bssid, create=create_peers), - self.get_peer_id(scan_value.ap_name, create=create_peers), - } - {None, ""} + self.get_peer_id(TypedIdentifier(PeerType.WIFI, scan_value.bssid), 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 @@ -137,7 +151,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: @@ -185,13 +199,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)