Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
## Mac stuff
.DS_Store

### Local secrets (pre-prod DB skill — see .cursor/skills/preprod-database/)
Meshflow/ai-env

### app outputs
state.json
nodes.json
Expand Down
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ Make sure to activate the venv `venv/bin/activate`

See **tests/TESTING.md** for detailed instructions (unit tests, integration tests via Docker Compose or local Django).

### Pre-production database (opt-in)

Agents must **not** query pre-prod unless the user explicitly enables it. When enabled, follow **[.cursor/skills/preprod-database/SKILL.md](.cursor/skills/preprod-database/SKILL.md)** (`disable-model-invocation`; copy credentials to local `Meshflow/ai-env`, gitignored).

## Code Style

- **Linting**: black, isort, flake8
Expand Down
47 changes: 40 additions & 7 deletions Meshflow/common/mc_channel_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from constellations.models import MeshCoreChannelType, MessageChannel
from nodes.models import ManagedNode, ManagedNodeMcChannelLink


def mc_channel_admin_label(channel: MessageChannel) -> str:
Expand All @@ -14,24 +15,42 @@ def mc_channel_admin_label(channel: MessageChannel) -> str:
name = (channel.name or "").strip()
if name:
return name
if channel.mc_channel_idx is not None:
return f"slot {channel.mc_channel_idx}"
return str(channel.pk)


def mc_channel_display_label(channel: MessageChannel) -> str:
"""Operator-facing label (Messages UI, constellation channel lists)."""
return mc_channel_admin_label(channel)


def mc_channel_type_name(channel: MessageChannel) -> str:
if channel.mc_channel_type is None:
return "—"
return MeshCoreChannelType(channel.mc_channel_type).name


def message_channel_to_apply_entry(channel: MessageChannel) -> dict:
"""Build one apply_mc_channel_config entry from a MessageChannel row."""
def message_channel_to_apply_entry(
channel: MessageChannel,
*,
managed_node: ManagedNode | None = None,
mc_channel_idx: int | None = None,
) -> dict:
"""Build one apply_mc_channel_config entry from a canonical channel and feeder slot."""
if mc_channel_idx is None and managed_node is not None:
link = ManagedNodeMcChannelLink.objects.filter(
managed_node=managed_node,
message_channel=channel,
).first()
if link is not None:
mc_channel_idx = link.mc_channel_idx
if mc_channel_idx is None:
raise ValueError("mc_channel_idx is required to apply channel config to a feeder device")

ch_type = mc_channel_type_name(channel)
if ch_type == "—":
ch_type = "PUBLIC"
entry = {
"mc_channel_idx": channel.mc_channel_idx,
"mc_channel_idx": mc_channel_idx,
"name": channel.name,
"mc_channel_type": ch_type,
}
Expand All @@ -43,8 +62,22 @@ def message_channel_to_apply_entry(channel: MessageChannel) -> dict:
return entry


def managed_node_mc_channel_links(managed_node: ManagedNode):
"""Feeder slot links with canonical channels, ordered by device index."""
from common.protocol import Protocol

return (
managed_node.mc_channel_links.filter(message_channel__protocol=Protocol.MESHCORE)
.select_related("message_channel")
.order_by("mc_channel_idx")
)


def managed_node_mc_channels_queryset(managed_node):
"""MC channel rows linked on a MeshCore feeder (device mirror)."""
"""Canonical MC channel rows for a MeshCore feeder (legacy queryset helper)."""
from common.protocol import Protocol

return managed_node.mc_channels.filter(protocol=Protocol.MESHCORE).order_by("mc_channel_idx")
return MessageChannel.objects.filter(
feeder_links__managed_node=managed_node,
protocol=Protocol.MESHCORE,
).order_by("feeder_links__mc_channel_idx")
5 changes: 1 addition & 4 deletions Meshflow/common/tests/test_mc_channel_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ def test_mc_channel_admin_label_public(create_constellation):
name="Public",
constellation=constellation,
protocol=Protocol.MESHCORE,
mc_channel_idx=0,
mc_channel_type=MeshCoreChannelType.PUBLIC,
)
assert mc_channel_admin_label(ch) == "Public"
Expand All @@ -30,7 +29,6 @@ def test_mc_channel_admin_label_hashtag_prefix(create_constellation):
name="galloway",
constellation=constellation,
protocol=Protocol.MESHCORE,
mc_channel_idx=1,
mc_channel_type=MeshCoreChannelType.HASHTAG,
mc_hashtag="galloway",
)
Expand All @@ -44,11 +42,10 @@ def test_message_channel_to_apply_entry_hashtag(create_constellation):
name="galloway",
constellation=constellation,
protocol=Protocol.MESHCORE,
mc_channel_idx=1,
mc_channel_type=MeshCoreChannelType.HASHTAG,
mc_hashtag="galloway",
)
entry = message_channel_to_apply_entry(ch)
entry = message_channel_to_apply_entry(ch, mc_channel_idx=1)
assert entry["mc_channel_type"] == "HASHTAG"
assert entry["mc_hashtag"] == "galloway"
assert entry["name"] == "galloway"
5 changes: 2 additions & 3 deletions Meshflow/constellations/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ class MeshCoreMessageChannelAdmin(admin.ModelAdmin):
"""Constellation MC channel catalog (device slots). Push to radio from Managed node admin."""

list_display = (
"mc_channel_idx",
"admin_label",
"mc_channel_type_display",
"constellation",
Expand All @@ -118,10 +117,10 @@ class MeshCoreMessageChannelAdmin(admin.ModelAdmin):
"constellation",
)
search_fields = ("name", "mc_hashtag", "constellation__name")
ordering = ("constellation__name", "mc_channel_idx")
ordering = ("constellation__name", "name")
list_select_related = ("constellation",)
fieldsets = (
(None, {"fields": ("constellation", "mc_channel_idx")}),
(None, {"fields": ("constellation",)}),
(
_("Channel"),
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Manual split for #379 — drop index uniqueness before link backfill

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("constellations", "0011_remove_constellationusermembership"),
]

operations = [
migrations.RemoveConstraint(
model_name="messagechannel",
name="messagechannel_mc_idx_constellation_unique",
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Placeholder dependency anchor between link backfill and finalize

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("constellations", "0012_remove_mc_idx_constraint"),
("nodes", "0051_mc_canonical_channels"),
]

operations = []
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Merge duplicate MC channels and drop per-row device index from MessageChannel

from django.db import migrations, models


MC_PROTOCOL = 2
MC_PUBLIC = 1
MC_HASHTAG = 2


def _logical_key(channel):
if channel.mc_channel_type == MC_HASHTAG and channel.mc_hashtag:
tag = str(channel.mc_hashtag).strip().lstrip("#").lower()
if tag:
return ("hashtag", tag)
name = str(channel.name or "").strip().lower()
return ("public", name)


def _repoint_channel_fks(apps, old_id, new_id):
if old_id == new_id:
return
TextMessage = apps.get_model("text_messages", "TextMessage")
TextMessage.objects.filter(channel_id=old_id).update(channel_id=new_id)

try:
MeshCoreTextPacket = apps.get_model("meshcore_packets", "MeshCoreTextPacket")
MeshCoreTextPacket.objects.filter(channel_id=old_id).update(channel_id=new_id)
except LookupError:
pass

try:
MeshCorePacketObservation = apps.get_model("meshcore_packets", "MeshCorePacketObservation")
MeshCorePacketObservation.objects.filter(channel_id=old_id).update(channel_id=new_id)
except LookupError:
pass

Link = apps.get_model("nodes", "ManagedNodeMcChannelLink")
Link.objects.filter(message_channel_id=old_id).update(message_channel_id=new_id)


def merge_duplicate_mc_channels(apps, schema_editor):
MessageChannel = apps.get_model("constellations", "MessageChannel")
channels = list(
MessageChannel.objects.filter(protocol=MC_PROTOCOL).exclude(mc_channel_idx__isnull=True)
)
groups = {}
for ch in channels:
key = (ch.constellation_id, _logical_key(ch))
groups.setdefault(key, []).append(ch)

for _key, group in groups.items():
if len(group) < 2:
continue
group.sort(key=lambda c: c.id)
survivor = group[0]
for dup in group[1:]:
_repoint_channel_fks(apps, dup.id, survivor.id)
MessageChannel.objects.filter(id=dup.id).delete()


class Migration(migrations.Migration):

dependencies = [
("constellations", "0013_mc_canonical_channels_backfill_links"),
("text_messages", "0008_textmessage_mc_provenance"),
("meshcore_packets", "0004_observation_path_hash_size_mode"),
]

operations = [
migrations.RunPython(merge_duplicate_mc_channels, migrations.RunPython.noop),
migrations.RemoveField(
model_name="messagechannel",
name="mc_channel_idx",
),
migrations.AddConstraint(
model_name="messagechannel",
constraint=models.UniqueConstraint(
condition=models.Q(
("mc_channel_type", MC_HASHTAG),
("mc_hashtag__isnull", False),
("protocol", MC_PROTOCOL),
),
fields=("constellation", "protocol", "mc_hashtag"),
name="messagechannel_mc_hashtag_constellation_unique",
),
),
migrations.AddConstraint(
model_name="messagechannel",
constraint=models.UniqueConstraint(
condition=models.Q(("mc_channel_type", MC_PUBLIC), ("protocol", MC_PROTOCOL)),
fields=("constellation", "protocol", "name"),
name="messagechannel_mc_public_name_constellation_unique",
),
),
]
23 changes: 15 additions & 8 deletions Meshflow/constellations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,6 @@ class MessageChannel(models.Model):
db_index=True,
help_text=_("Mesh protocol for this channel row."),
)
mc_channel_idx = models.PositiveSmallIntegerField(
null=True,
blank=True,
help_text=_("MeshCore channel index when protocol is MeshCore; null for Meshtastic."),
)
mc_channel_type = models.PositiveSmallIntegerField(
choices=MeshCoreChannelType.choices,
null=True,
Expand All @@ -70,9 +65,21 @@ class Meta:
verbose_name_plural = _("Message channels")
constraints = [
models.UniqueConstraint(
fields=["constellation", "protocol", "mc_channel_idx"],
condition=models.Q(protocol=Protocol.MESHCORE, mc_channel_idx__isnull=False),
name="messagechannel_mc_idx_constellation_unique",
fields=["constellation", "protocol", "mc_hashtag"],
condition=models.Q(
protocol=Protocol.MESHCORE,
mc_channel_type=MeshCoreChannelType.HASHTAG,
mc_hashtag__isnull=False,
),
name="messagechannel_mc_hashtag_constellation_unique",
),
models.UniqueConstraint(
fields=["constellation", "protocol", "name"],
condition=models.Q(
protocol=Protocol.MESHCORE,
mc_channel_type=MeshCoreChannelType.PUBLIC,
),
name="messagechannel_mc_public_name_constellation_unique",
),
]

Expand Down
8 changes: 6 additions & 2 deletions Meshflow/constellations/serializers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from rest_framework import serializers

from common.mc_channel_labels import mc_channel_display_label
from common.protocol import Protocol
from constellations.models import Constellation, MeshCoreChannelType, MessageChannel


Expand All @@ -8,15 +10,17 @@ def message_channel_payload(channel: MessageChannel) -> dict:
mc_type = None
if channel.mc_channel_type is not None:
mc_type = MeshCoreChannelType(channel.mc_channel_type).label
return {
payload = {
"id": channel.id,
"name": channel.name,
"protocol": channel.protocol,
"mc_channel_idx": channel.mc_channel_idx,
"mc_channel_type": mc_type,
"mc_hashtag": channel.mc_hashtag,
"constellation": channel.constellation_id,
}
if channel.protocol == Protocol.MESHCORE:
payload["display_label"] = mc_channel_display_label(channel)
return payload


class ConstellationSerializer(serializers.ModelSerializer):
Expand Down
6 changes: 3 additions & 3 deletions Meshflow/constellations/tests/test_constellation_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from rest_framework.test import APIClient

from common.protocol import Protocol
from constellations.models import Constellation, MessageChannel
from constellations.models import Constellation, MeshCoreChannelType, MessageChannel


@pytest.mark.django_db
Expand Down Expand Up @@ -45,7 +45,7 @@ def test_constellation_list_protocol_filter(create_constellation, create_user):
name="MC Ch",
constellation=mc,
protocol=Protocol.MESHCORE,
mc_channel_idx=1,
mc_channel_type=MeshCoreChannelType.PUBLIC,
)

response = client.get(reverse("constellation-list"), {"protocol": "meshcore"})
Expand Down Expand Up @@ -118,7 +118,7 @@ def test_guest_can_list_channels(create_constellation, create_user):
name="MC",
constellation=constellation,
protocol=Protocol.MESHCORE,
mc_channel_idx=0,
mc_channel_type=MeshCoreChannelType.PUBLIC,
)

url = reverse("constellation-channels-list-create", kwargs={"constellation_id": constellation.id})
Expand Down
11 changes: 9 additions & 2 deletions Meshflow/dx_monitoring/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class Migration(migrations.Migration):
dependencies = [
('constellations', '0005_add_bot_default_env_vars'),
('nodes', '0032_add_update_managed_node_statuses_periodic_task'),
('packets', '0016_packetobservation_observer_upload_time_idx'),
('packets', '0018_rename_packet_metrics_meshtastic_fields'),
]

operations = [
Expand Down Expand Up @@ -62,7 +62,14 @@ class Migration(migrations.Migration):
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='dx_monitoring.dxevent')),
('observer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dx_event_observations', to='nodes.managednode')),
('packet_observation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dx_event_observations', to='packets.packetobservation')),
('raw_packet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dx_event_observations', to='packets.rawpacket')),
(
'raw_packet',
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='dx_event_observations',
to='packets.mtrawpacket',
),
),
],
options={
'verbose_name': 'DX event observation',
Expand Down
Loading