Skip to content

feat(wifi): make the mDNS name configurable, and stop orphaning DNS-SD instances - #113

Open
skialpine wants to merge 1 commit into
decentespresso:mainfrom
skialpine:feat/configurable-mdns-name
Open

feat(wifi): make the mDNS name configurable, and stop orphaning DNS-SD instances#113
skialpine wants to merge 1 commit into
decentespresso:mainfrom
skialpine:feat/configurable-mdns-name

Conversation

@skialpine

Copy link
Copy Markdown
Contributor

Where this came from: I now have two Half Decent Scales on one network. That is the whole origin of this PR — everything here is a problem you only meet with a second scale on the LAN.

The problem

Every scale asks for the same identity. src/wifi_setup.cpp hardcoded all three strings:

MDNS.begin("hds");
MDNS.setInstanceName("Half Decent Scale");
WiFi.setHostname("hds.local");

The mDNS stack does resolve the collision — the loser gets suffixed to hds-2.local — so both scales stay reachable. But:

  • Which scale is hds vs hds-2 follows boot order. Power them up the other way round and they swap addresses, so a bookmark or app config cannot pin a specific scale.
  • hds-2 tells you nothing. Not which scale is by the grinder.
  • Hostname and instance name are probed independently. Observed live with two scales up: 192.168.10.145 was at hds-2.local labelled Half Decent Scale, while 192.168.10.241 was at hds.local labelled Half Decent Scale-2. Each won one race and lost the other, so address and browser label disagreed on both units at once.

Also, WiFi.setHostname("hds.local") passed a .local suffix to a DHCP hostname, where it does not belong.

What this changes

A device name in the NVS wifi namespace under mdns_name, defaulting to hds. A scale that is never renamed behaves exactly as it does today — same hds.local, same Half Decent Scale instance name, no migration, no schema bump.

The name drives:

Target Default Renamed to kitchen
MDNS.begin() hds kitchen
WiFi.setHostname() hds kitchen
DNS-SD instance Half Decent Scale Half Decent Scale (kitchen)
TXT record name=hds name=kitchen

Renaming is POST /setup/name, driven by a "Scale Name" field in the WiFi setup area of the on-device web UI. The name also appears in the /snapshot status frame as mdns_name, and on the OLED WiFi status screen so it can be recovered without network access.

Second thing: stale DNS-SD instances

stopWifi() never called MDNS.end(), and that call is what emits the DNS-SD goodbye. So every deliberate reboot orphaned its instance registration: the PTR record sat in every resolver cache on the LAN for its full TTL — 75 minutes by DNS-SD convention, against 2 minutes for SRV/A — which is exactly why a browse would list instances that never resolve.

A _decentscale._tcp browse here returned four instances for two scales, two of them ghosts.

stopWifi() now withdraws first, while the radio is still up. Every deliberate teardown routes through it — remote and USB reset, rename and wifi-setup reboots, deep sleep — so one call site covers them all. The heap-watchdog esp_restart() stays best-effort by nature.

This bug predates the naming feature (the Half Decent Scale-2 ghost came from plain 3.1.12) and is separable if you would rather take it on its own.

Worth being explicit about what it does not fix: a goodbye is one unacknowledged multicast, and a crash, flat battery, or unplug sends nothing at all. Clients still have to treat an unresolvable browse hit as "not a scale".

Name rules

include/mdns_name.h, deliberately Arduino-free so the native env can test it: 1-24 characters of [a-z0-9-], no leading or trailing hyphen, trimmed and lowercased, and an empty value restores the default hds.

Reposting the name a scale already has is a no-op — no NVS write, no restart, response says "restarting":false. The web UI prefills the field, so without that check a stray submit would reboot the scale mid-shot, and a repeated POST of the current name would hold it in a reboot loop.

Firmware constraints respected

  • The /setup/name handler runs on the AsyncTCP task, so it touches no mDNS entry point and publishes no String the main loop reads. It writes NVS and queues the existing deferred restart, the same shape /setup/wifi already uses.
  • WiFi.setHostname() is only read before association, which is why a rename restarts rather than re-advertising live.
  • The OLED name line is drawn inside showWifiStatus()'s existing firstPage()/nextPage() loop — no nested page loop.
  • The name lives in the wifi namespace, not hds, so the settings schema and its migration contract are untouched.

Verification

Host-side:

pio test -e native -f test_mdns_name     8/8 passed
python tools/test_mdns_name_contract.py  passed
python tools/test_ai_docs_contract.py    passed
python tools/test_storage_migration_contract.py  passed
pio run -e esp32s3                       SUCCESS (RAM 18.4%, Flash 49.8%)

nightly.yml now runs the new contract check and a native-tests job — neither the unit tests nor the contract script had any CI coverage before, since no workflow ran pio test.

On hardware (two scales on one LAN):

  • Default boot answers hds.local, TXT name=hds, status mdns_name='hds'.
  • {"name":" Kitchen "}200 {"name":"kitchen","restarting":true}; after reboot kitchen.local resolves, instance is Half Decent Scale (kitchen), TXT name=kitchen, status agrees.
  • {"name":""} restores hds.
  • Rejected with 400: my scale, hds.local, -bad, and a body with no name.
  • Three rapid posts of the current name: uptime ran 80000 → 90000 ms unbroken, heap flat — no reboot.
  • Passive 5353 capture across a rename shows the withdrawal working:
[16.8s] PTR ttl=0     Half Decent Scale (hdstest)     <<< goodbye, before the radio drops
[25.9s] PTR ttl=4500  Half Decent Scale (hdstest2)

A follow-up browse listed only live instances — no hdstest ghost.

One observation for the record: ESP-IDF sends the goodbye for the PTR record only, not SRV/TXT/A. That is the right one to withdraw, since PTR carries the 4500 s TTL that causes the ghosts while SRV/A expire on their own in 120 s.

🤖 Generated with Claude Code

Every scale hardcoded MDNS.begin("hds"), MDNS.setInstanceName("Half Decent
Scale"), and WiFi.setHostname("hds.local"), so two scales on one LAN both
asked for the same identity. The mDNS stack resolves that collision by
suffixing the loser to hds-2.local, but which scale wins follows boot order,
and hostname and instance name are probed independently -- so a browse can
show a scale whose address and label disagree.

Store a device name in the NVS wifi namespace under mdns_name, defaulting to
"hds" so an unrenamed scale still answers at hds.local. The name drives the
mDNS hostname, the DHCP hostname (now a bare label, not "hds.local"), the
DNS-SD instance name, and a new name= TXT record. Rename over POST
/setup/name, from the Scale Name field in the web UI's WiFi setup area.

Also withdraw the DNS-SD registration on deliberate shutdown. stopWifi()
never called MDNS.end(), which is what emits the goodbye, so every rename or
reset orphaned an instance in resolver caches for the PTR TTL (75 min,
against 2 min for SRV/A) -- browsable but unresolvable. That bug predates
this change.

Validation lives in include/mdns_name.h, Arduino-free so the native env can
test it: 1-24 chars of [a-z0-9-], no leading or trailing hyphen, trimmed and
lowercased, empty restores the default. Reposting the current name is a no-op
that does not write NVS and does not restart, so a stray submit or a repeated
POST cannot reboot the scale mid-shot.

Verified on hardware: default boot answers hds.local; rename normalizes and
reboots to <name>.local advertising "Half Decent Scale (<name>)" with TXT
name=<name>; a passive 5353 capture shows the ttl=0 PTR goodbye for the old
instance before the radio drops; repeated no-op posts leave uptime unbroken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@skialpine
skialpine force-pushed the feat/configurable-mdns-name branch from 7a7a5c7 to e661983 Compare July 29, 2026 19:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant