mesh debugging and control
This commit is contained in:
parent
4c40597cd3
commit
df6efbc8d5
11 changed files with 201 additions and 12 deletions
|
@ -9,7 +9,8 @@ from itertools import chain
|
||||||
import pytz
|
import pytz
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.db.models import Prefetch
|
from django.db.models import Prefetch
|
||||||
from django.forms import ChoiceField, Form, IntegerField, ModelForm, Select
|
from django.forms import ChoiceField, Form, IntegerField, ModelForm, Select, MultipleChoiceField, \
|
||||||
|
ModelMultipleChoiceField
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils.translation import ngettext_lazy
|
from django.utils.translation import ngettext_lazy
|
||||||
|
@ -19,6 +20,8 @@ from c3nav.mapdata.forms import I18nModelFormMixin
|
||||||
from c3nav.mapdata.models import MapUpdate, Space
|
from c3nav.mapdata.models import MapUpdate, Space
|
||||||
from c3nav.mapdata.models.access import (AccessPermission, AccessPermissionToken, AccessPermissionTokenItem,
|
from c3nav.mapdata.models.access import (AccessPermission, AccessPermissionToken, AccessPermissionTokenItem,
|
||||||
AccessRestriction, AccessRestrictionGroup)
|
AccessRestriction, AccessRestrictionGroup)
|
||||||
|
from c3nav.mesh.messages import MessageType
|
||||||
|
from c3nav.mesh.models import MeshNode
|
||||||
from c3nav.site.models import Announcement
|
from c3nav.site.models import Announcement
|
||||||
|
|
||||||
|
|
||||||
|
@ -295,3 +298,16 @@ class MapUpdateForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MapUpdate
|
model = MapUpdate
|
||||||
fields = ('geometries_changed', )
|
fields = ('geometries_changed', )
|
||||||
|
|
||||||
|
|
||||||
|
class MeshMessageFilerForm(Form):
|
||||||
|
message_types = MultipleChoiceField(
|
||||||
|
choices=[(msgtype.value, msgtype.name) for msgtype in MessageType],
|
||||||
|
required=False,
|
||||||
|
label=_('message types'),
|
||||||
|
)
|
||||||
|
nodes = ModelMultipleChoiceField(
|
||||||
|
queryset=MeshNode.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('nodes'),
|
||||||
|
)
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<p>
|
<p>
|
||||||
{% if page_obj.has_previous %}
|
{% if page_obj.has_previous %}
|
||||||
<a href="?{% if request.GET.s %}s={{ request.GET.s | urlencode }}&{% endif %}page=1">
|
<a href="?{% if request.GET.s %}s={{ request.GET.s | urlencode }}&{% endif %}page=1{% if form_data %}&{{ form_data }}{% endif %}">
|
||||||
« {% trans 'first' %}
|
« {% trans 'first' %}
|
||||||
</a> ·
|
</a> ·
|
||||||
<a href="?{% if request.GET.s %}s={{ request.GET.s | urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}">
|
<a href="?{% if request.GET.s %}s={{ request.GET.s | urlencode }}&{% endif %}page={{ page_obj.previous_page_number }}{% if form_data %}&{{ form_data }}{% endif %}">
|
||||||
‹ {% trans 'previous' %}
|
‹ {% trans 'previous' %}
|
||||||
</a> ·
|
</a> ·
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -12,10 +12,10 @@
|
||||||
{% blocktrans %}Page {{ page_number }} of {{ num_pages }}{% endblocktrans %}
|
{% blocktrans %}Page {{ page_number }} of {{ num_pages }}{% endblocktrans %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
{% if page_obj.has_next %}
|
{% if page_obj.has_next %}
|
||||||
· <a href="?{% if request.GET.s %}s={{ request.GET.s | urlencode }}&{% endif %}page={{ page_obj.next_page_number }}">
|
· <a href="?{% if request.GET.s %}s={{ request.GET.s | urlencode }}&{% endif %}page={{ page_obj.next_page_number }}{% if form_data %}&{{ form_data }}{% endif %}">
|
||||||
{% trans 'next' %} ›
|
{% trans 'next' %} ›
|
||||||
</a>
|
</a>
|
||||||
· <a href="?{% if request.GET.s %}s={{ request.GET.s | urlencode }}&{% endif %}page={{ page_obj.paginator.num_pages }}">
|
· <a href="?{% if request.GET.s %}s={{ request.GET.s | urlencode }}&{% endif %}page={{ page_obj.paginator.num_pages }}{% if form_data %}&{{ form_data }}{% endif %}">
|
||||||
{% trans 'last' %} »
|
{% trans 'last' %} »
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
50
src/c3nav/control/templates/control/mesh_messages.html
Normal file
50
src/c3nav/control/templates/control/mesh_messages.html
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
{% extends 'control/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block heading %}{% trans 'Mesh messages' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block subcontent %}
|
||||||
|
<form>
|
||||||
|
<div class="fields">
|
||||||
|
<div class="field">
|
||||||
|
{{ form.message_types }}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
{{ form.nodes }}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<button type="submit">Filer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% include 'control/fragment_pagination.html' %}
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans 'Time' %}</th>
|
||||||
|
<th>{% trans 'Node' %}</th>
|
||||||
|
<th>{% trans 'Type' %}</th>
|
||||||
|
<th>{% trans 'Data' %}</th>
|
||||||
|
</tr>
|
||||||
|
{% for msg in mesh_messages %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ msg.datetime }}</td>
|
||||||
|
<td>{{ msg.node }}</td>
|
||||||
|
<td>{{ msg.get_message_type_display }}</td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
{% for key, value in msg.data.items %}
|
||||||
|
{% if key != "src" and key != "dst" and key != "msg_id" %}
|
||||||
|
<div class="mesh-msg-data mesh-msg-type-{{ key }}">
|
||||||
|
<strong>{{ key }}</strong>: {{ value }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% include 'control/fragment_pagination.html' %}
|
||||||
|
{% endblock %}
|
|
@ -8,7 +8,9 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>{% trans 'Address' %}</th>
|
<th>{% trans 'Address' %}</th>
|
||||||
<th>{% trans 'Name' %}</th>
|
<th>{% trans 'Name' %}</th>
|
||||||
<th>{% trans 'Last seen' %}</th>
|
<th>{% trans 'Chip' %}</th>
|
||||||
|
<th>{% trans 'Firmware' %}</th>
|
||||||
|
<th>{% trans 'Last msg' %}</th>
|
||||||
<th>{% trans 'Parent' %}</th>
|
<th>{% trans 'Parent' %}</th>
|
||||||
<th>{% trans 'Route' %}</th>
|
<th>{% trans 'Route' %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -16,7 +18,13 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ node.address }}</td>
|
<td>{{ node.address }}</td>
|
||||||
<td>{{ node.name }}</td>
|
<td>{{ node.name }}</td>
|
||||||
<td>{{ node.last_seen }}</td>
|
<td>
|
||||||
|
{{ node.firmware.get_chip_display }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ node.firmware.version }} (IDF {{ node.firmware.idf_version }})
|
||||||
|
</td>
|
||||||
|
<td>{{ node.last_msg }}</td>
|
||||||
<td>{{ node.parent }}</td>
|
<td>{{ node.parent }}</td>
|
||||||
<td>{{ node.route }}</td>
|
<td>{{ node.route }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from c3nav.control.views.mesh import MeshNodeListView
|
from c3nav.control.views.mesh import MeshNodeListView, MeshMessageListView
|
||||||
from c3nav.control.views.mapupdates import map_updates
|
from c3nav.control.views.mapupdates import map_updates
|
||||||
from c3nav.control.views.announcements import announcement_list, announcement_detail
|
from c3nav.control.views.announcements import announcement_list, announcement_detail
|
||||||
from c3nav.control.views.access import grant_access, grant_access_qr
|
from c3nav.control.views.access import grant_access, grant_access_qr
|
||||||
|
@ -16,5 +16,6 @@ urlpatterns = [
|
||||||
path('announcements/<int:annoucement>/', announcement_detail, name='control.announcements.detail'),
|
path('announcements/<int:annoucement>/', announcement_detail, name='control.announcements.detail'),
|
||||||
path('mapupdates/', map_updates, name='control.map_updates'),
|
path('mapupdates/', map_updates, name='control.map_updates'),
|
||||||
path('mesh/', MeshNodeListView.as_view(), name='control.mesh_nodes'),
|
path('mesh/', MeshNodeListView.as_view(), name='control.mesh_nodes'),
|
||||||
|
path('mesh/messages/', MeshMessageListView.as_view(), name='control.mesh_messages'),
|
||||||
path('', ControlPanelIndexView.as_view(), name='control.index'),
|
path('', ControlPanelIndexView.as_view(), name='control.index'),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,7 +1,10 @@
|
||||||
|
from django.db.models import Max
|
||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
|
from django.views.generic.edit import FormMixin
|
||||||
|
|
||||||
|
from c3nav.control.forms import MeshMessageFilerForm
|
||||||
from c3nav.control.views.base import ControlPanelMixin
|
from c3nav.control.views.base import ControlPanelMixin
|
||||||
from c3nav.mesh.models import MeshNode
|
from c3nav.mesh.models import MeshNode, NodeMessage
|
||||||
|
|
||||||
|
|
||||||
class MeshNodeListView(ControlPanelMixin, ListView):
|
class MeshNodeListView(ControlPanelMixin, ListView):
|
||||||
|
@ -9,3 +12,39 @@ class MeshNodeListView(ControlPanelMixin, ListView):
|
||||||
template_name = "control/mesh_nodes.html"
|
template_name = "control/mesh_nodes.html"
|
||||||
ordering = "address"
|
ordering = "address"
|
||||||
context_object_name = "nodes"
|
context_object_name = "nodes"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return super().get_queryset().annotate(last_msg=Max('received_messages__datetime'))
|
||||||
|
|
||||||
|
|
||||||
|
class MeshMessageListView(ControlPanelMixin, ListView):
|
||||||
|
model = NodeMessage
|
||||||
|
template_name = "control/mesh_messages.html"
|
||||||
|
ordering = "-datetime"
|
||||||
|
paginate_by = 20
|
||||||
|
context_object_name = "mesh_messages"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super().get_queryset()
|
||||||
|
|
||||||
|
self.form = MeshMessageFilerForm(self.request.GET)
|
||||||
|
if self.form.is_valid():
|
||||||
|
if self.form.cleaned_data['message_types']:
|
||||||
|
qs = qs.filter(message_type__in=self.form.cleaned_data['message_types'])
|
||||||
|
if self.form.cleaned_data['nodes']:
|
||||||
|
qs = qs.filter(node__in=self.form.cleaned_data['nodes'])
|
||||||
|
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def get_context_data(self, *args, **kwargs):
|
||||||
|
ctx = super().get_context_data(*args, **kwargs)
|
||||||
|
|
||||||
|
form_data = self.request.GET.copy()
|
||||||
|
form_data.pop('page', None)
|
||||||
|
|
||||||
|
ctx.update({
|
||||||
|
'form': self.form,
|
||||||
|
'form_data': form_data.urlencode(),
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ from channels.db import database_sync_to_async
|
||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||||
|
|
||||||
from c3nav.mesh import messages
|
from c3nav.mesh import messages
|
||||||
from c3nav.mesh.models import MeshNode, NodeMessage
|
from c3nav.mesh.models import MeshNode, NodeMessage, Firmware
|
||||||
|
|
||||||
|
|
||||||
class MeshConsumer(AsyncWebsocketConsumer):
|
class MeshConsumer(AsyncWebsocketConsumer):
|
||||||
|
@ -69,6 +69,16 @@ class MeshConsumer(AsyncWebsocketConsumer):
|
||||||
|
|
||||||
await self.log_received_message(msg)
|
await self.log_received_message(msg)
|
||||||
|
|
||||||
|
if isinstance(msg, messages.ConfigFirmwareMessage):
|
||||||
|
await self._handle_config_firmware_msg(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _handle_config_firmware_msg(self, msg):
|
||||||
|
self.firmware, created = Firmware.objects.get_or_create(**msg.to_model_data())
|
||||||
|
self.node.firmware = self.firmware
|
||||||
|
self.node.save()
|
||||||
|
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def get_node(self, address):
|
def get_node(self, address):
|
||||||
return MeshNode.objects.get_or_create(address=address)
|
return MeshNode.objects.get_or_create(address=address)
|
||||||
|
@ -100,3 +110,8 @@ class MeshConsumer(AsyncWebsocketConsumer):
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
def remove_route_to_nodes(self, route_address, node_addresses):
|
def remove_route_to_nodes(self, route_address, node_addresses):
|
||||||
MeshNode.objects.filter(address__in=node_addresses, route_id=route_address).update(route_id=None)
|
MeshNode.objects.filter(address__in=node_addresses, route_id=route_address).update(route_id=None)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def set_node_firmware(self, firmware):
|
||||||
|
self.node.firmware = firmware
|
||||||
|
self.node.save()
|
|
@ -112,6 +112,8 @@ class ConfigDumpMessage(Message, msg_id=MessageType.CONFIG_DUMP):
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ConfigFirmwareMessage(Message, msg_id=MessageType.CONFIG_FIRMWARE):
|
class ConfigFirmwareMessage(Message, msg_id=MessageType.CONFIG_FIRMWARE):
|
||||||
|
chip: int = field(metadata={'format': SimpleFormat('H')})
|
||||||
|
revision: int = field(metadata={'format': SimpleFormat('2B')})
|
||||||
magic_word: int = field(metadata={'format': SimpleFormat('I')}, repr=False)
|
magic_word: int = field(metadata={'format': SimpleFormat('I')}, repr=False)
|
||||||
secure_version: int = field(metadata={'format': SimpleFormat('I')})
|
secure_version: int = field(metadata={'format': SimpleFormat('I')})
|
||||||
reserv1: list[int] = field(metadata={'format': SimpleFormat('2I')}, repr=False)
|
reserv1: list[int] = field(metadata={'format': SimpleFormat('2I')}, repr=False)
|
||||||
|
@ -123,6 +125,15 @@ class ConfigFirmwareMessage(Message, msg_id=MessageType.CONFIG_FIRMWARE):
|
||||||
app_elf_sha256: str = field(metadata={'format': HexFormat(32)})
|
app_elf_sha256: str = field(metadata={'format': HexFormat(32)})
|
||||||
reserv2: list[int] = field(metadata={'format': SimpleFormat('20I')}, repr=False)
|
reserv2: list[int] = field(metadata={'format': SimpleFormat('20I')}, repr=False)
|
||||||
|
|
||||||
|
def to_model_data(self):
|
||||||
|
return {
|
||||||
|
'chip': self.chip,
|
||||||
|
'project_name': self.project_name,
|
||||||
|
'version': self.version,
|
||||||
|
'idf_version': self.idf_version,
|
||||||
|
'sha256_hash': self.app_elf_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ConfigPositionMessage(Message, msg_id=MessageType.CONFIG_POSITION):
|
class ConfigPositionMessage(Message, msg_id=MessageType.CONFIG_POSITION):
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Generated by Django 4.2.1 on 2023-10-02 19:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mesh', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='firmware',
|
||||||
|
unique_together={('chip', 'project_name', 'version', 'idf_version', 'sha256_hash')},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='meshnode',
|
||||||
|
name='firmware',
|
||||||
|
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='installed_on', to='mesh.firmware', verbose_name='firmware'),
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='firmware',
|
||||||
|
name='compile_time',
|
||||||
|
),
|
||||||
|
]
|
18
src/c3nav/mesh/migrations/0003_meshnode_name.py
Normal file
18
src/c3nav/mesh/migrations/0003_meshnode_name.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 4.2.1 on 2023-10-02 19:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('mesh', '0002_alter_firmware_unique_together_meshnode_firmware_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='meshnode',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(blank=True, max_length=32, null=True, verbose_name='name'),
|
||||||
|
),
|
||||||
|
]
|
|
@ -11,13 +11,18 @@ class ChipID(models.IntegerChoices):
|
||||||
|
|
||||||
class MeshNode(models.Model):
|
class MeshNode(models.Model):
|
||||||
address = models.CharField(_('mac address'), max_length=17, primary_key=True)
|
address = models.CharField(_('mac address'), max_length=17, primary_key=True)
|
||||||
|
name = models.CharField(_('name'), max_length=32, null=True, blank=True)
|
||||||
first_seen = models.DateTimeField(_('first seen'), auto_now_add=True)
|
first_seen = models.DateTimeField(_('first seen'), auto_now_add=True)
|
||||||
parent_node = models.ForeignKey('MeshNode', models.PROTECT, null=True,
|
parent_node = models.ForeignKey('MeshNode', models.PROTECT, null=True,
|
||||||
related_name='child_nodes', verbose_name=_('parent node'))
|
related_name='child_nodes', verbose_name=_('parent node'))
|
||||||
route = models.ForeignKey('MeshNode', models.PROTECT, null=True,
|
route = models.ForeignKey('MeshNode', models.PROTECT, null=True,
|
||||||
related_name='routed_nodes', verbose_name=_('route'))
|
related_name='routed_nodes', verbose_name=_('route'))
|
||||||
|
firmware = models.ForeignKey('Firmware', models.PROTECT, null=True,
|
||||||
|
related_name='installed_on', verbose_name=_('firmware'))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
if self.name:
|
||||||
|
return '%s (%s)' % (self.address, self.name)
|
||||||
return self.address
|
return self.address
|
||||||
|
|
||||||
|
|
||||||
|
@ -38,11 +43,10 @@ class Firmware(models.Model):
|
||||||
project_name = models.CharField(_('project name'), max_length=32)
|
project_name = models.CharField(_('project name'), max_length=32)
|
||||||
version = models.CharField(_('firmware version'), max_length=32)
|
version = models.CharField(_('firmware version'), max_length=32)
|
||||||
idf_version = models.CharField(_('IDF version'), max_length=32)
|
idf_version = models.CharField(_('IDF version'), max_length=32)
|
||||||
compile_time = models.DateTimeField(_('compile time'))
|
|
||||||
sha256_hash = models.CharField(_('SHA256 hash'), unique=True, max_length=64)
|
sha256_hash = models.CharField(_('SHA256 hash'), unique=True, max_length=64)
|
||||||
binary = models.FileField(_('firmware file'), null=True)
|
binary = models.FileField(_('firmware file'), null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [
|
unique_together = [
|
||||||
('chip', 'project_name', 'version', 'idf_version', 'compile_time', 'sha256_hash'),
|
('chip', 'project_name', 'version', 'idf_version', 'sha256_hash'),
|
||||||
]
|
]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue