2023-11-30 21:26:39 +01:00
|
|
|
|
import string
|
2023-11-06 18:26:19 +01:00
|
|
|
|
from collections import UserDict, namedtuple
|
2023-11-10 16:19:57 +01:00
|
|
|
|
from contextlib import suppress
|
2023-11-06 18:26:19 +01:00
|
|
|
|
from dataclasses import dataclass
|
2023-11-07 16:35:46 +01:00
|
|
|
|
from datetime import datetime, timedelta
|
2023-10-03 17:23:29 +02:00
|
|
|
|
from functools import cached_property
|
2023-10-04 23:24:49 +02:00
|
|
|
|
from operator import attrgetter
|
2023-11-06 18:26:19 +01:00
|
|
|
|
from typing import Any, Mapping, Optional, Self
|
2023-10-03 17:23:29 +02:00
|
|
|
|
|
2023-12-01 14:58:47 +01:00
|
|
|
|
import channels
|
2023-11-05 18:47:20 +01:00
|
|
|
|
from django.contrib.auth import get_user_model
|
2023-11-10 19:00:09 +01:00
|
|
|
|
from django.core.validators import RegexValidator
|
2023-10-06 02:46:43 +02:00
|
|
|
|
from django.db import NotSupportedError, models
|
2023-11-06 14:22:35 +01:00
|
|
|
|
from django.db.models import Q, UniqueConstraint
|
2023-11-07 16:35:46 +01:00
|
|
|
|
from django.utils import timezone
|
2023-11-30 21:26:39 +01:00
|
|
|
|
from django.utils.crypto import get_random_string
|
2023-11-05 18:47:20 +01:00
|
|
|
|
from django.utils.text import slugify
|
2022-04-15 20:02:42 +02:00
|
|
|
|
from django.utils.translation import gettext_lazy as _
|
|
|
|
|
|
2023-11-25 20:14:07 +01:00
|
|
|
|
from c3nav.mapdata.models.geometry.space import RangingBeacon
|
2024-02-27 18:25:18 +01:00
|
|
|
|
from c3nav.mesh.schemas import BoardType, ChipType, FirmwareImage
|
2023-11-17 18:56:47 +01:00
|
|
|
|
from c3nav.mesh.messages import ConfigFirmwareMessage, ConfigHardwareMessage
|
2023-10-06 02:46:43 +02:00
|
|
|
|
from c3nav.mesh.messages import MeshMessage as MeshMessage
|
|
|
|
|
from c3nav.mesh.messages import MeshMessageType
|
2023-12-01 15:00:40 +01:00
|
|
|
|
from c3nav.mesh.utils import MESH_ALL_OTA_GROUP, UPLINK_TIMEOUT
|
2023-12-07 02:15:32 +01:00
|
|
|
|
from c3nav.routing.locator import Locator
|
2023-10-03 17:23:29 +02:00
|
|
|
|
|
2023-11-06 18:26:19 +01:00
|
|
|
|
FirmwareLookup = namedtuple('FirmwareLookup', ('sha256_hash', 'chip', 'project_name', 'version', 'idf_version'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class FirmwareDescription:
|
|
|
|
|
chip: ChipType
|
|
|
|
|
project_name: str
|
|
|
|
|
version: str
|
|
|
|
|
idf_version: str
|
|
|
|
|
sha256_hash: str
|
|
|
|
|
build: Optional["FirmwareBuild"] = None
|
|
|
|
|
created: datetime | None = None
|
|
|
|
|
|
|
|
|
|
def get_lookup(self) -> FirmwareLookup:
|
|
|
|
|
return FirmwareLookup(
|
|
|
|
|
chip=self.chip,
|
|
|
|
|
project_name=self.project_name,
|
|
|
|
|
version=self.version,
|
|
|
|
|
idf_version=self.idf_version,
|
|
|
|
|
sha256_hash=self.sha256_hash,
|
|
|
|
|
)
|
|
|
|
|
|
2023-10-03 17:23:29 +02:00
|
|
|
|
|
2023-11-10 16:08:55 +01:00
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class HardwareDescription:
|
|
|
|
|
chip: ChipType
|
|
|
|
|
board: BoardType
|
|
|
|
|
|
|
|
|
|
|
2023-10-03 17:23:29 +02:00
|
|
|
|
class MeshNodeQuerySet(models.QuerySet):
|
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
self._prefetch_last_messages = set()
|
|
|
|
|
self._prefetch_last_messages_done = False
|
2023-11-06 18:26:19 +01:00
|
|
|
|
self._prefetch_firmwares = False
|
2023-11-10 16:08:55 +01:00
|
|
|
|
self._prefetch_ota = False
|
|
|
|
|
self._prefetch_ota_done = False
|
2023-11-25 20:14:07 +01:00
|
|
|
|
self._prefetch_ranging_beacon = False
|
|
|
|
|
self._prefetch_ranging_beacon_done = False
|
2023-10-03 17:23:29 +02:00
|
|
|
|
|
|
|
|
|
def _clone(self):
|
|
|
|
|
clone = super()._clone()
|
|
|
|
|
clone._prefetch_last_messages = self._prefetch_last_messages
|
2023-11-06 18:26:19 +01:00
|
|
|
|
clone._prefetch_firmwares = self._prefetch_firmwares
|
2023-11-10 16:08:55 +01:00
|
|
|
|
clone._prefetch_ota = self._prefetch_ota
|
2023-11-25 20:14:07 +01:00
|
|
|
|
clone._prefetch_ranging_beacon = self._prefetch_ranging_beacon
|
2023-10-03 17:23:29 +02:00
|
|
|
|
return clone
|
|
|
|
|
|
2023-10-04 15:44:54 +02:00
|
|
|
|
def prefetch_last_messages(self, *types: MeshMessageType):
|
2023-10-03 17:23:29 +02:00
|
|
|
|
clone = self._chain()
|
|
|
|
|
clone._prefetch_last_messages |= (
|
2023-10-20 15:23:45 +02:00
|
|
|
|
set(types) if types else set(msgtype for msgtype in MeshMessageType)
|
2023-10-03 17:23:29 +02:00
|
|
|
|
)
|
|
|
|
|
return clone
|
|
|
|
|
|
2023-11-30 22:45:53 +01:00
|
|
|
|
def prefetch_firmwares(self):
|
2023-11-06 18:26:19 +01:00
|
|
|
|
clone = self.prefetch_last_messages(MeshMessageType.CONFIG_FIRMWARE,
|
|
|
|
|
MeshMessageType.CONFIG_HARDWARE)
|
|
|
|
|
clone._prefetch_firmwares = True
|
|
|
|
|
return clone
|
|
|
|
|
|
2023-11-10 16:08:55 +01:00
|
|
|
|
def prefetch_ota(self):
|
|
|
|
|
clone = self._chain()
|
2023-11-26 00:35:39 +01:00
|
|
|
|
clone._prefetch_ota = True
|
2023-11-10 16:08:55 +01:00
|
|
|
|
return clone
|
|
|
|
|
|
2023-11-25 20:14:07 +01:00
|
|
|
|
def prefetch_ranging_beacon(self):
|
2024-03-29 18:36:17 +01:00
|
|
|
|
clone = self.prefetch_last_messages(MeshMessageType.CONFIG_NODE)
|
2023-11-25 20:14:07 +01:00
|
|
|
|
clone._prefetch_ranging_beacon = True
|
|
|
|
|
return clone
|
|
|
|
|
|
2023-10-03 17:23:29 +02:00
|
|
|
|
def _fetch_all(self):
|
|
|
|
|
super()._fetch_all()
|
2024-03-29 18:36:17 +01:00
|
|
|
|
nodes_by_bssid = None
|
2023-10-03 17:23:29 +02:00
|
|
|
|
if self._prefetch_last_messages and not self._prefetch_last_messages_done:
|
2024-03-29 18:36:17 +01:00
|
|
|
|
nodes_by_bssid: dict[str, MeshNode] = {node.pk: node for node in self._result_cache}
|
2023-10-03 17:23:29 +02:00
|
|
|
|
try:
|
2023-10-20 14:43:00 +02:00
|
|
|
|
for message in NodeMessage.objects.order_by('message_type', 'src_node', '-datetime', '-pk').filter(
|
2023-10-20 15:23:45 +02:00
|
|
|
|
message_type__in=(t.name for t in self._prefetch_last_messages),
|
2024-03-29 18:36:17 +01:00
|
|
|
|
src_node__in=nodes_by_bssid.keys(),
|
2023-11-06 14:22:35 +01:00
|
|
|
|
).prefetch_related("uplink").distinct('message_type', 'src_node'):
|
2024-03-29 18:36:17 +01:00
|
|
|
|
nodes_by_bssid[message.src_node_id].last_messages[message.message_type] = message
|
|
|
|
|
for node in nodes_by_bssid.values():
|
2023-11-26 15:13:59 +01:00
|
|
|
|
node.last_messages["any"] = (
|
|
|
|
|
max(node.last_messages.values(), key=attrgetter("datetime"))
|
|
|
|
|
if node.last_messages else None
|
|
|
|
|
)
|
2023-11-06 18:26:19 +01:00
|
|
|
|
self._prefetch_last_messages_done = True
|
2023-10-03 17:23:29 +02:00
|
|
|
|
except NotSupportedError:
|
|
|
|
|
pass
|
2022-04-15 20:02:42 +02:00
|
|
|
|
|
2023-11-06 18:26:19 +01:00
|
|
|
|
if self._prefetch_firmwares:
|
|
|
|
|
# fetch matching firmware builds
|
|
|
|
|
firmwares = {
|
|
|
|
|
fw_desc.get_lookup(): fw_desc for fw_desc in
|
2023-11-10 16:19:57 +01:00
|
|
|
|
(build.firmware_description for build in FirmwareBuild.objects.filter(
|
2023-11-06 18:26:19 +01:00
|
|
|
|
sha256_hash__in=set(
|
2024-03-29 15:40:32 +01:00
|
|
|
|
node.last_messages[MeshMessageType.CONFIG_FIRMWARE].parsed.content.app_desc.app_elf_sha256
|
2023-11-06 18:26:19 +01:00
|
|
|
|
for node in self._result_cache
|
2023-11-26 15:13:59 +01:00
|
|
|
|
if node.last_messages[MeshMessageType.CONFIG_FIRMWARE]
|
2023-11-06 18:26:19 +01:00
|
|
|
|
)
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# assign firmware descriptions
|
2024-03-29 18:36:17 +01:00
|
|
|
|
for node in nodes_by_bssid.values():
|
2023-11-10 16:19:57 +01:00
|
|
|
|
firmware_desc = node.firmware_description
|
2023-11-26 15:13:59 +01:00
|
|
|
|
node._firmware_description = (
|
|
|
|
|
firmwares.get(firmware_desc.get_lookup(), firmware_desc)
|
|
|
|
|
if firmware_desc else None
|
|
|
|
|
)
|
2023-11-06 18:26:19 +01:00
|
|
|
|
|
|
|
|
|
# get date of first appearance
|
|
|
|
|
nodes_to_complete = tuple(
|
2024-03-29 18:36:17 +01:00
|
|
|
|
node for node in nodes_by_bssid.values()
|
2023-11-26 15:13:59 +01:00
|
|
|
|
if node._firmware_description and node._firmware_description.build is None
|
2023-11-06 18:26:19 +01:00
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
created_lookup = {
|
2024-03-29 15:40:32 +01:00
|
|
|
|
msg.parsed.content.app_desc.app_elf_sha256: msg.datetime
|
2023-11-06 18:26:19 +01:00
|
|
|
|
for msg in NodeMessage.objects.filter(
|
|
|
|
|
message_type=MeshMessageType.CONFIG_FIRMWARE.name,
|
2024-03-29 17:07:12 +01:00
|
|
|
|
data__content__app_desc__app_elf_sha256__in=(node._firmware_description.sha256_hash
|
2024-03-29 16:14:20 +01:00
|
|
|
|
for node in nodes_to_complete)
|
2024-03-29 18:08:46 +01:00
|
|
|
|
).order_by('data__content__app_desc__app_elf_sha256',
|
|
|
|
|
'datetime').distinct('data__content__app_desc__app_elf_sha256')
|
2023-11-06 18:26:19 +01:00
|
|
|
|
}
|
|
|
|
|
print(created_lookup)
|
|
|
|
|
except NotSupportedError:
|
|
|
|
|
created_lookup = {
|
|
|
|
|
app_elf_sha256: NodeMessage.objects.filter(
|
|
|
|
|
message_type=MeshMessageType.CONFIG_FIRMWARE.name,
|
2024-03-29 17:07:12 +01:00
|
|
|
|
data__content__app_desc__app_elf_sha256=app_elf_sha256
|
2024-03-29 16:43:42 +01:00
|
|
|
|
).order_by('datetime').first().datetime
|
2023-12-02 02:13:30 +01:00
|
|
|
|
for app_elf_sha256 in {node._firmware_description.sha256_hash for node in nodes_to_complete}
|
2023-11-06 18:26:19 +01:00
|
|
|
|
}
|
|
|
|
|
for node in nodes_to_complete:
|
2024-03-29 17:07:12 +01:00
|
|
|
|
node._firmware_description.created = created_lookup[node._firmware_description.sha256_hash]
|
2023-11-06 18:26:19 +01:00
|
|
|
|
|
2023-11-10 16:08:55 +01:00
|
|
|
|
if self._prefetch_ota and not self._prefetch_ota_done:
|
2024-03-29 18:36:17 +01:00
|
|
|
|
if nodes_by_bssid is None:
|
|
|
|
|
nodes_by_bssid: dict[str, MeshNode] = {node.pk: node for node in self._result_cache}
|
2023-11-10 16:08:55 +01:00
|
|
|
|
try:
|
2023-11-26 16:22:55 +01:00
|
|
|
|
for ota in OTAUpdateRecipient.objects.filter(
|
2024-03-29 18:36:17 +01:00
|
|
|
|
node__in=nodes_by_bssid.keys(),
|
2023-11-26 16:22:55 +01:00
|
|
|
|
status=OTARecipientStatus.RUNNING,
|
|
|
|
|
).select_related("update", "update__build"):
|
2023-11-10 16:08:55 +01:00
|
|
|
|
# noinspection PyUnresolvedReferences
|
2024-03-29 18:36:17 +01:00
|
|
|
|
nodes_by_bssid[ota.node_id]._current_ota = ota
|
|
|
|
|
for node in nodes_by_bssid.values():
|
2023-11-10 16:08:55 +01:00
|
|
|
|
if not hasattr(node, "_current_ota"):
|
|
|
|
|
node._current_ota = None
|
|
|
|
|
self._prefetch_ota_done = True
|
|
|
|
|
except NotSupportedError:
|
|
|
|
|
pass
|
|
|
|
|
|
2023-11-25 20:14:07 +01:00
|
|
|
|
if self._prefetch_ranging_beacon and not self._prefetch_ranging_beacon_done:
|
2024-03-29 18:36:17 +01:00
|
|
|
|
if nodes_by_bssid is None:
|
|
|
|
|
nodes_by_bssid: dict[str, MeshNode] = {
|
|
|
|
|
node.pk: node
|
|
|
|
|
for node in self._result_cache
|
|
|
|
|
}
|
|
|
|
|
nodes_by_id: dict[int, MeshNode] = {
|
|
|
|
|
node.last_messages[MeshMessageType.CONFIG_NODE].parsed.content.number: node
|
|
|
|
|
for node in self._result_cache
|
2024-03-29 22:34:59 +01:00
|
|
|
|
if node.last_messages[MeshMessageType.CONFIG_NODE]
|
2024-03-29 18:36:17 +01:00
|
|
|
|
}
|
2024-12-27 01:40:32 +01:00
|
|
|
|
nodes_by_bssid_keys = frozenset(nodes_by_bssid.keys())
|
2023-11-25 20:14:07 +01:00
|
|
|
|
try:
|
2024-12-27 01:40:32 +01:00
|
|
|
|
for ranging_beacon in RangingBeacon.objects.filter(
|
|
|
|
|
Q(node_number__in=nodes_by_id.keys())
|
|
|
|
|
).select_related('space'):
|
2024-12-28 15:21:53 +01:00
|
|
|
|
if not (set(ranging_beacon.addresses) & nodes_by_bssid_keys):
|
2024-12-27 01:40:32 +01:00
|
|
|
|
continue
|
2023-11-25 20:14:07 +01:00
|
|
|
|
# noinspection PyUnresolvedReferences
|
2024-12-28 15:21:53 +01:00
|
|
|
|
for bssid in ranging_beacon.addresses:
|
2024-12-27 01:40:32 +01:00
|
|
|
|
with suppress(KeyError):
|
|
|
|
|
nodes_by_bssid[bssid]._ranging_beacon = ranging_beacon
|
2024-03-29 18:36:17 +01:00
|
|
|
|
with suppress(KeyError):
|
|
|
|
|
nodes_by_id[ranging_beacon.node_number]._ranging_beacon = ranging_beacon
|
|
|
|
|
# todo: detect and warn about conflicts
|
|
|
|
|
for node in nodes_by_bssid.values():
|
2023-11-25 20:14:07 +01:00
|
|
|
|
if not hasattr(node, "_ranging_beacon"):
|
|
|
|
|
node._ranging_beacon = None
|
|
|
|
|
self._prefetch_ranging_beacon_done = True
|
|
|
|
|
except NotSupportedError:
|
|
|
|
|
pass
|
|
|
|
|
|
2023-10-06 02:46:43 +02:00
|
|
|
|
|
2023-10-03 17:23:29 +02:00
|
|
|
|
class LastMessagesByTypeLookup(UserDict):
|
|
|
|
|
def __init__(self, node):
|
|
|
|
|
super().__init__()
|
|
|
|
|
self.node = node
|
|
|
|
|
|
|
|
|
|
def _get_key(self, item):
|
2023-10-04 15:44:54 +02:00
|
|
|
|
if isinstance(item, MeshMessageType):
|
2023-10-03 17:23:29 +02:00
|
|
|
|
return item
|
|
|
|
|
if isinstance(item, str):
|
|
|
|
|
try:
|
2023-10-04 15:44:54 +02:00
|
|
|
|
return getattr(MeshMessageType, item)
|
2023-10-03 17:23:29 +02:00
|
|
|
|
except AttributeError:
|
|
|
|
|
pass
|
2023-10-04 15:44:54 +02:00
|
|
|
|
return MeshMessageType(item)
|
2023-10-03 17:23:29 +02:00
|
|
|
|
|
|
|
|
|
def __getitem__(self, key):
|
2023-10-04 23:24:49 +02:00
|
|
|
|
if key == "any":
|
|
|
|
|
msg = self.node.received_messages.order_by('-datetime', '-pk').first()
|
|
|
|
|
self.data["any"] = msg
|
|
|
|
|
return msg
|
2023-10-03 17:23:29 +02:00
|
|
|
|
key = self._get_key(key)
|
|
|
|
|
try:
|
|
|
|
|
return self.data[key]
|
|
|
|
|
except KeyError:
|
|
|
|
|
pass
|
2023-10-20 19:20:50 +02:00
|
|
|
|
msg = self.node.received_messages.filter(message_type=key.name).order_by('-datetime', '-pk').first()
|
2023-10-03 17:23:29 +02:00
|
|
|
|
self.data[key] = msg
|
|
|
|
|
return msg
|
|
|
|
|
|
|
|
|
|
def __setitem__(self, key, item):
|
2023-10-20 14:43:00 +02:00
|
|
|
|
if key == "any":
|
|
|
|
|
self.data["any"] = item
|
|
|
|
|
return
|
2023-10-03 17:23:29 +02:00
|
|
|
|
self.data[self._get_key(key)] = item
|
2022-04-15 20:02:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MeshNode(models.Model):
|
2023-11-06 14:22:35 +01:00
|
|
|
|
"""
|
|
|
|
|
A nesh node. Any node.
|
|
|
|
|
"""
|
2023-11-10 19:00:09 +01:00
|
|
|
|
address = models.CharField(_('mac address'), max_length=17, primary_key=True,
|
|
|
|
|
validators=[RegexValidator(
|
|
|
|
|
regex='^([a-f0-9]{2}:){5}[a-f0-9]{2}$',
|
|
|
|
|
message='Must be a lower-case mac address',
|
|
|
|
|
code='invalid_macaddress'
|
|
|
|
|
)])
|
|
|
|
|
|
2022-04-15 20:02:42 +02:00
|
|
|
|
first_seen = models.DateTimeField(_('first seen'), auto_now_add=True)
|
2023-11-06 14:22:35 +01:00
|
|
|
|
uplink = models.ForeignKey('MeshUplink', models.PROTECT, null=True,
|
2023-10-03 17:51:49 +02:00
|
|
|
|
related_name='routed_nodes', verbose_name=_('uplink'))
|
2024-03-30 19:04:12 +01:00
|
|
|
|
upstream = models.ForeignKey('MeshNode', models.SET_NULL, null=True,
|
|
|
|
|
related_name='downstream', verbose_name=_('parent node'))
|
2023-10-03 17:51:49 +02:00
|
|
|
|
last_signin = models.DateTimeField(_('last signin'), null=True)
|
2023-10-03 17:23:29 +02:00
|
|
|
|
objects = models.Manager.from_queryset(MeshNodeQuerySet)()
|
2022-04-15 20:02:42 +02:00
|
|
|
|
|
2024-03-30 22:12:55 +01:00
|
|
|
|
@property
|
|
|
|
|
def name(self):
|
|
|
|
|
node_message = self.last_messages[MeshMessageType.CONFIG_NODE]
|
|
|
|
|
if node_message:
|
2024-03-30 22:14:02 +01:00
|
|
|
|
return f"{node_message.parsed.content.number} – {node_message.parsed.content.name}".strip()
|
2024-03-30 22:12:55 +01:00
|
|
|
|
|
2022-04-15 20:02:42 +02:00
|
|
|
|
def __str__(self):
|
2023-10-02 22:02:25 +02:00
|
|
|
|
if self.name:
|
|
|
|
|
return '%s (%s)' % (self.address, self.name)
|
2022-04-15 20:02:42 +02:00
|
|
|
|
return self.address
|
|
|
|
|
|
2023-10-03 17:23:29 +02:00
|
|
|
|
@cached_property
|
2023-11-06 18:26:19 +01:00
|
|
|
|
def last_messages(self) -> Mapping[Any, "NodeMessage"]:
|
2023-10-03 17:23:29 +02:00
|
|
|
|
return LastMessagesByTypeLookup(self)
|
|
|
|
|
|
2023-11-10 16:08:55 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
def current_ota(self) -> Optional["OTAUpdateRecipient"]:
|
|
|
|
|
try:
|
|
|
|
|
# noinspection PyUnresolvedReferences
|
|
|
|
|
return self._current_ota
|
|
|
|
|
except AttributeError:
|
2023-11-26 16:22:55 +01:00
|
|
|
|
return self.ota_updates.select_related("update", "update__build").filter(
|
|
|
|
|
status=OTARecipientStatus.RUNNING
|
|
|
|
|
).first()
|
2023-11-10 16:08:55 +01:00
|
|
|
|
|
2023-11-25 20:14:07 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
def ranging_beacon(self) -> Optional["RangingBeacon"]:
|
|
|
|
|
try:
|
|
|
|
|
# noinspection PyUnresolvedReferences
|
|
|
|
|
return self._ranging_beacon
|
|
|
|
|
except AttributeError:
|
|
|
|
|
return RangingBeacon.objects.filter(bssid=self.address).first()
|
|
|
|
|
|
2023-11-10 16:19:57 +01:00
|
|
|
|
# noinspection PyUnresolvedReferences
|
2023-12-02 02:24:10 +01:00
|
|
|
|
@property
|
2023-11-26 15:13:59 +01:00
|
|
|
|
def firmware_description(self) -> Optional[FirmwareDescription]:
|
2023-11-10 16:19:57 +01:00
|
|
|
|
with suppress(AttributeError):
|
|
|
|
|
return self._firmware_description
|
2023-11-26 15:13:59 +01:00
|
|
|
|
|
|
|
|
|
fw_msg = self.last_messages[MeshMessageType.CONFIG_FIRMWARE]
|
|
|
|
|
hw_msg = self.last_messages[MeshMessageType.CONFIG_HARDWARE]
|
|
|
|
|
if not fw_msg or not hw_msg:
|
|
|
|
|
return None
|
|
|
|
|
|
2023-11-10 16:08:55 +01:00
|
|
|
|
# noinspection PyTypeChecker
|
2023-11-26 15:13:59 +01:00
|
|
|
|
firmware_msg: ConfigFirmwareMessage = fw_msg.parsed
|
2023-11-10 16:08:55 +01:00
|
|
|
|
# noinspection PyTypeChecker
|
2023-11-26 15:13:59 +01:00
|
|
|
|
hardware_msg: ConfigHardwareMessage = hw_msg.parsed
|
2023-11-06 18:26:19 +01:00
|
|
|
|
return FirmwareDescription(
|
2024-03-29 16:05:17 +01:00
|
|
|
|
chip=hardware_msg.content.chip,
|
|
|
|
|
project_name=firmware_msg.content.app_desc.project_name,
|
|
|
|
|
version=firmware_msg.content.app_desc.version,
|
|
|
|
|
idf_version=firmware_msg.content.app_desc.idf_version,
|
|
|
|
|
sha256_hash=firmware_msg.content.app_desc.app_elf_sha256,
|
2023-11-06 18:26:19 +01:00
|
|
|
|
)
|
|
|
|
|
|
2023-11-10 16:19:57 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
def hardware_description(self) -> HardwareDescription:
|
2023-11-10 16:08:55 +01:00
|
|
|
|
# noinspection PyUnresolvedReferences
|
2023-11-26 15:13:59 +01:00
|
|
|
|
|
|
|
|
|
hw_msg = self.last_messages[MeshMessageType.CONFIG_HARDWARE]
|
|
|
|
|
board_msg = self.last_messages[MeshMessageType.CONFIG_BOARD]
|
2023-11-10 16:08:55 +01:00
|
|
|
|
return HardwareDescription(
|
2024-03-29 16:05:17 +01:00
|
|
|
|
chip=hw_msg.parsed.content.chip if hw_msg else None,
|
|
|
|
|
board=board_msg.parsed.content.board_config.board if board_msg else None,
|
2023-11-10 16:08:55 +01:00
|
|
|
|
)
|
|
|
|
|
|
2023-11-09 18:27:57 +01:00
|
|
|
|
# overriden by prefetch_firmwares()
|
|
|
|
|
firmware_desc = None
|
|
|
|
|
|
2023-11-06 19:22:23 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
def chip(self) -> ChipType:
|
|
|
|
|
return self.last_messages[MeshMessageType.CONFIG_HARDWARE].parsed.chip
|
|
|
|
|
|
|
|
|
|
@cached_property
|
|
|
|
|
def board(self) -> ChipType:
|
2023-11-10 16:08:55 +01:00
|
|
|
|
# noinspection PyUnresolvedReferences
|
2023-11-06 19:22:23 +01:00
|
|
|
|
return self.last_messages[MeshMessageType.CONFIG_BOARD].parsed.board_config.board
|
|
|
|
|
|
2023-11-07 16:35:46 +01:00
|
|
|
|
def get_uplink(self) -> Optional["MeshUplink"]:
|
|
|
|
|
if self.uplink_id is None:
|
|
|
|
|
return None
|
|
|
|
|
if self.uplink.last_ping + timedelta(seconds=UPLINK_TIMEOUT) < timezone.now():
|
|
|
|
|
return None
|
|
|
|
|
return self.uplink
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def get_node_and_uplink(self, address) -> Optional["MeshUplink"]:
|
|
|
|
|
try:
|
|
|
|
|
dst_node = MeshNode.objects.select_related('uplink').get(address=address)
|
|
|
|
|
except MeshNode.DoesNotExist:
|
|
|
|
|
return False
|
|
|
|
|
return dst_node.get_uplink()
|
|
|
|
|
|
2023-11-23 15:02:21 +01:00
|
|
|
|
def get_locator_xyz(self):
|
2024-02-01 16:59:04 +01:00
|
|
|
|
try:
|
|
|
|
|
locator = Locator.load()
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return None
|
2023-11-23 15:02:21 +01:00
|
|
|
|
return locator.get_xyz(self.address)
|
2023-11-10 20:11:50 +01:00
|
|
|
|
|
2022-04-15 20:02:42 +02:00
|
|
|
|
|
2023-11-06 14:22:35 +01:00
|
|
|
|
class MeshUplink(models.Model):
|
|
|
|
|
"""
|
|
|
|
|
An uplink session, a direct connection to a node
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
class EndReason(models.TextChoices):
|
|
|
|
|
CLOSED = "closed", _("closed")
|
|
|
|
|
REPLACED = "replaced", _("replaced")
|
|
|
|
|
NEW_TIMEOUT = "new_timeout", _("new (timeout)")
|
|
|
|
|
|
|
|
|
|
name = models.CharField(_('channel name'), max_length=128)
|
|
|
|
|
started = models.DateTimeField(_('started'), auto_now_add=True)
|
2023-11-10 16:08:55 +01:00
|
|
|
|
node = models.ForeignKey(MeshNode, models.PROTECT, related_name='uplink_sessions',
|
2023-11-06 14:22:35 +01:00
|
|
|
|
verbose_name=_('node'))
|
|
|
|
|
last_ping = models.DateTimeField(_('last ping from consumer'))
|
|
|
|
|
end_reason = models.CharField(_('end reason'), choices=EndReason.choices, null=True, max_length=16)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
constraints = (
|
|
|
|
|
UniqueConstraint(fields=["node"], condition=Q(end_reason__isnull=True), name='only_one_active_uplink'),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2022-04-15 20:02:42 +02:00
|
|
|
|
class NodeMessage(models.Model):
|
2023-10-20 15:23:45 +02:00
|
|
|
|
MESSAGE_TYPES = [(msgtype.name, msgtype.pretty_name) for msgtype in MeshMessageType]
|
2023-11-10 16:08:55 +01:00
|
|
|
|
src_node = models.ForeignKey(MeshNode, models.PROTECT, related_name='received_messages',
|
|
|
|
|
verbose_name=_('node'))
|
|
|
|
|
uplink = models.ForeignKey(MeshUplink, models.PROTECT, related_name='relayed_messages',
|
2023-11-06 14:22:35 +01:00
|
|
|
|
verbose_name=_('uplink'))
|
2022-04-15 20:02:42 +02:00
|
|
|
|
datetime = models.DateTimeField(_('datetime'), db_index=True, auto_now_add=True)
|
2023-10-20 15:23:45 +02:00
|
|
|
|
message_type = models.CharField(_('message type'), max_length=24, db_index=True, choices=MESSAGE_TYPES)
|
2022-04-15 20:02:42 +02:00
|
|
|
|
data = models.JSONField(_('message data'))
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return '(#%d) %s at %s' % (self.pk, self.get_message_type_display(), self.datetime)
|
|
|
|
|
|
2023-10-03 17:23:29 +02:00
|
|
|
|
@cached_property
|
2024-03-29 15:40:32 +01:00
|
|
|
|
def parsed(self) -> MeshMessage:
|
2024-02-27 18:25:18 +01:00
|
|
|
|
return MeshMessage.model_validate(self.data)
|
2023-10-03 17:23:29 +02:00
|
|
|
|
|
2022-04-15 20:02:42 +02:00
|
|
|
|
|
2023-11-05 18:47:20 +01:00
|
|
|
|
class FirmwareVersion(models.Model):
|
2022-04-15 20:02:42 +02:00
|
|
|
|
project_name = models.CharField(_('project name'), max_length=32)
|
2023-11-05 18:47:20 +01:00
|
|
|
|
version = models.CharField(_('firmware version'), max_length=32, unique=True)
|
2022-04-15 20:02:42 +02:00
|
|
|
|
idf_version = models.CharField(_('IDF version'), max_length=32)
|
2023-11-05 18:47:20 +01:00
|
|
|
|
uploader = models.ForeignKey(get_user_model(), null=True, on_delete=models.SET_NULL)
|
|
|
|
|
created = models.DateTimeField(_('creation/upload date'), auto_now_add=True)
|
|
|
|
|
|
|
|
|
|
def serialize(self):
|
|
|
|
|
return {
|
|
|
|
|
'project_name': self.project_name,
|
|
|
|
|
'version': self.version,
|
|
|
|
|
'idf_version': self.idf_version,
|
|
|
|
|
'created': self.created.isoformat(),
|
|
|
|
|
'builds': {
|
|
|
|
|
build.variant: build.serialize()
|
|
|
|
|
for build in self.builds.all().prefetch_related("firmwarebuildboard_set")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def firmware_upload_path(instance, filename):
|
|
|
|
|
# file will be uploaded to MEDIA_ROOT/user_<id>/<filename>
|
|
|
|
|
version = slugify(instance.version.version)
|
|
|
|
|
variant = slugify(instance.variant)
|
2023-11-30 21:26:39 +01:00
|
|
|
|
random_string = get_random_string(32, string.ascii_letters + string.digits)
|
|
|
|
|
return f"firmware/{version}/{variant}/{random_string}/{filename}"
|
2023-11-05 18:47:20 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FirmwareBuild(models.Model):
|
2024-03-10 13:45:20 +01:00
|
|
|
|
CHIPS = [(chiptype.c_value, chiptype.pretty_name) for chiptype in ChipType]
|
2023-11-05 18:47:20 +01:00
|
|
|
|
|
|
|
|
|
version = models.ForeignKey(FirmwareVersion, related_name='builds', on_delete=models.CASCADE)
|
|
|
|
|
variant = models.CharField(_('variant name'), max_length=64)
|
|
|
|
|
chip = models.SmallIntegerField(_('chip'), db_index=True, choices=CHIPS)
|
2022-04-15 20:02:42 +02:00
|
|
|
|
sha256_hash = models.CharField(_('SHA256 hash'), unique=True, max_length=64)
|
2023-11-05 19:09:36 +01:00
|
|
|
|
project_description = models.JSONField(verbose_name=_('project_description.json'))
|
2023-11-05 18:47:20 +01:00
|
|
|
|
binary = models.FileField(_('firmware file'), null=True, upload_to=firmware_upload_path)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
unique_together = [
|
|
|
|
|
('version', 'variant'),
|
|
|
|
|
]
|
2023-11-26 17:55:23 +01:00
|
|
|
|
|
2023-11-05 18:47:20 +01:00
|
|
|
|
@property
|
|
|
|
|
def boards(self):
|
2023-11-18 21:29:35 +01:00
|
|
|
|
return {BoardType[board.board] for board in self.firmwarebuildboard_set.all()
|
|
|
|
|
if board.board in BoardType._member_names_}
|
2023-11-05 18:47:20 +01:00
|
|
|
|
|
2023-11-10 16:08:55 +01:00
|
|
|
|
@property
|
|
|
|
|
def chip_type(self) -> ChipType:
|
|
|
|
|
return ChipType(self.chip)
|
|
|
|
|
|
2023-11-05 18:47:20 +01:00
|
|
|
|
def serialize(self):
|
|
|
|
|
return {
|
|
|
|
|
'chip': ChipType(self.chip).name,
|
|
|
|
|
'sha256_hash': self.sha256_hash,
|
|
|
|
|
'url': self.binary.url,
|
2023-11-11 03:01:15 +01:00
|
|
|
|
'boards': [board.name for board in self.boards],
|
2023-11-05 18:47:20 +01:00
|
|
|
|
}
|
|
|
|
|
|
2023-11-10 16:19:57 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
def firmware_description(self) -> FirmwareDescription:
|
2023-11-06 18:26:19 +01:00
|
|
|
|
return FirmwareDescription(
|
2023-11-10 16:08:55 +01:00
|
|
|
|
chip=self.chip_type,
|
2023-11-06 18:26:19 +01:00
|
|
|
|
project_name=self.version.project_name,
|
|
|
|
|
version=self.version.version,
|
|
|
|
|
idf_version=self.version.idf_version,
|
|
|
|
|
sha256_hash=self.sha256_hash,
|
|
|
|
|
created=self.version.created,
|
|
|
|
|
build=self,
|
|
|
|
|
)
|
|
|
|
|
|
2023-11-10 16:19:57 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
def hardware_descriptions(self) -> list[HardwareDescription]:
|
2023-11-10 16:08:55 +01:00
|
|
|
|
return [
|
|
|
|
|
HardwareDescription(
|
|
|
|
|
chip=self.chip_type,
|
|
|
|
|
board=board,
|
|
|
|
|
)
|
|
|
|
|
for board in self.boards
|
|
|
|
|
]
|
|
|
|
|
|
2023-11-17 18:56:47 +01:00
|
|
|
|
@cached_property
|
|
|
|
|
def firmware_image(self) -> FirmwareImage:
|
2023-11-17 19:04:43 +01:00
|
|
|
|
return FirmwareImage.from_file(self.binary.open('rb'))
|
2023-11-17 18:56:47 +01:00
|
|
|
|
|
2023-11-05 18:47:20 +01:00
|
|
|
|
|
|
|
|
|
class FirmwareBuildBoard(models.Model):
|
|
|
|
|
BOARDS = [(boardtype.name, boardtype.pretty_name) for boardtype in BoardType]
|
|
|
|
|
build = models.ForeignKey(FirmwareBuild, on_delete=models.CASCADE)
|
|
|
|
|
board = models.CharField(_('board'), max_length=32, db_index=True, choices=BOARDS)
|
2022-04-15 20:02:42 +02:00
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
unique_together = [
|
2023-11-05 18:47:20 +01:00
|
|
|
|
('build', 'board'),
|
2022-04-15 20:02:42 +02:00
|
|
|
|
]
|
2023-11-10 16:08:55 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OTAUpdate(models.Model):
|
|
|
|
|
build = models.ForeignKey(FirmwareBuild, on_delete=models.CASCADE)
|
|
|
|
|
created = models.DateTimeField(_('creation'), auto_now_add=True)
|
|
|
|
|
|
2023-11-26 16:22:55 +01:00
|
|
|
|
@property
|
|
|
|
|
def grouped_recipients(self):
|
|
|
|
|
result = {}
|
|
|
|
|
for recipient in self.recipients.all():
|
|
|
|
|
result.setdefault(recipient.get_status_display(), []).append(recipient)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OTARecipientStatus(models.TextChoices):
|
|
|
|
|
RUNNING = "running", _("running")
|
|
|
|
|
REPLACED = "replaced", _("replaced")
|
|
|
|
|
CANCELED = "canceled", _("canceled")
|
|
|
|
|
FAILED = "failed", _("failed")
|
|
|
|
|
SUCCESS = "success", _("success")
|
|
|
|
|
|
2023-11-10 16:08:55 +01:00
|
|
|
|
|
|
|
|
|
class OTAUpdateRecipient(models.Model):
|
|
|
|
|
update = models.ForeignKey(OTAUpdate, on_delete=models.CASCADE, related_name='recipients')
|
|
|
|
|
node = models.ForeignKey(MeshNode, models.PROTECT, related_name='ota_updates',
|
|
|
|
|
verbose_name=_('node'))
|
2023-11-26 16:22:55 +01:00
|
|
|
|
status = models.CharField(max_length=10, choices=OTARecipientStatus.choices, default=OTARecipientStatus.RUNNING,
|
|
|
|
|
verbose_name=_('status'))
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
constraints = (
|
|
|
|
|
UniqueConstraint(fields=["node"], condition=Q(status=OTARecipientStatus.RUNNING),
|
|
|
|
|
name='only_one_active_ota'),
|
|
|
|
|
)
|
2023-12-01 14:58:47 +01:00
|
|
|
|
|
|
|
|
|
async def send_status(self):
|
|
|
|
|
"""
|
|
|
|
|
use this for OTA stuffs
|
|
|
|
|
"""
|
2023-12-02 02:24:53 +01:00
|
|
|
|
await channels.layers.get_channel_layer().group_send(MESH_ALL_OTA_GROUP, self.get_status_json())
|
2023-12-01 14:58:47 +01:00
|
|
|
|
|
|
|
|
|
def get_status_json(self):
|
|
|
|
|
return {
|
|
|
|
|
"type": "mesh.ota_recipient_status",
|
|
|
|
|
"node": self.node_id,
|
|
|
|
|
"update": self.update_id,
|
|
|
|
|
"status": self.status,
|
|
|
|
|
}
|