station: Default device type to "unknown" and propagate DHCP hostname

GuessDeviceType silently returned "mobile" for any device whose hostname
or MAC OUI didn't match a known pattern, so the UI labelled every
unidentified device as a phone. Default to "unknown" instead and broaden
hostname matching (pixel/galaxy, thinkpad, imac/-pc, QEMU OUI).

The hostapd backend was also dropping DHCP hostnames on the floor: the
correlator only forwarded MAC->IP, and convertStation hard-coded the
hostname to "". Replace UpdateIPMapping with UpdateLeaseInfo that carries
both maps so hostnames flow through to ConnectedDevice.Name.

Frontend gains a "Sans nom" fallback when no hostname is available and
French labels for the device-type badge.
This commit is contained in:
nemunaire 2026-05-01 22:21:14 +08:00
commit a758c331c0
4 changed files with 68 additions and 35 deletions

View file

@ -401,13 +401,17 @@ function displayDevices(devices) {
const deviceCard = document.createElement('div');
deviceCard.className = 'device-card';
const displayName = device.name && device.name.trim() !== ''
? device.name
: 'Sans nom';
deviceCard.innerHTML = `
${getDeviceIcon(device.type)}
<div class="device-name">${escapeHtml(device.name)}</div>
<div class="device-type">${device.type}</div>
<div class="device-name">${escapeHtml(displayName)}</div>
<div class="device-type">${escapeHtml(formatDeviceType(device.type))}</div>
<div class="device-info">
<div>${device.ip}</div>
<div style="font-size: 0.75rem;">${device.mac}</div>
<div>${escapeHtml(device.ip || '—')}</div>
<div style="font-size: 0.75rem;">${escapeHtml(device.mac)}</div>
</div>
`;
@ -415,6 +419,17 @@ function displayDevices(devices) {
});
}
function formatDeviceType(type) {
const labels = {
mobile: 'Mobile',
tablet: 'Tablette',
laptop: 'Portable',
desktop: 'Ordinateur',
unknown: 'Inconnu'
};
return labels[type] || labels.unknown;
}
function addLogEntry(log) {
const logContainer = document.getElementById('logContainer');

View file

@ -87,19 +87,26 @@ type BackendConfig struct {
HostapdInterface string // Hostapd interface name for DBus
}
// GuessDeviceType attempts to guess device type from hostname and MAC address
// GuessDeviceType attempts to guess device type from hostname and MAC address.
// Returns "unknown" when no signal is strong enough — the frontend renders that
// as a neutral icon rather than mislabeling unknown devices as "mobile".
func GuessDeviceType(hostname, mac string) string {
hostname = strings.ToLower(hostname)
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") {
if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") ||
strings.Contains(hostname, "pixel") || strings.Contains(hostname, "galaxy") {
return "mobile"
} else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") {
return "tablet"
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") {
} else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") ||
strings.Contains(hostname, "thinkpad") {
return "laptop"
} else if strings.Contains(hostname, "imac") || strings.Contains(hostname, "desktop") ||
strings.Contains(hostname, "-pc") {
return "desktop"
}
// Guess by MAC prefix (OUI)
// Guess by MAC prefix (OUI) — VM hypervisor MACs are almost always laptops
if len(mac) >= 8 {
macPrefix := strings.ToUpper(mac[:8])
switch macPrefix {
@ -107,8 +114,8 @@ func GuessDeviceType(hostname, mac string) string {
return "laptop"
case "08:00:27": // VirtualBox
return "laptop"
default:
return "mobile"
case "52:54:00": // QEMU/KVM
return "laptop"
}
}

View file

@ -27,17 +27,19 @@ type Backend struct {
stopCh chan struct{}
stopOnce sync.Once
// IP correlation - will be populated by periodic DHCP lease correlation
ipByMAC map[string]string // MAC -> IP mapping
// IP / hostname correlation - populated by periodic DHCP lease correlation
ipByMAC map[string]string // MAC -> IP mapping
hostnameByMAC map[string]string // MAC -> hostname mapping
}
// NewBackend creates a new hostapd backend
func NewBackend() *Backend {
return &Backend{
stations: make(map[string]*HostapdStation),
ipByMAC: make(map[string]string),
hostapdCLI: "hostapd_cli",
stopCh: make(chan struct{}),
stations: make(map[string]*HostapdStation),
ipByMAC: make(map[string]string),
hostnameByMAC: make(map[string]string),
hostapdCLI: "hostapd_cli",
stopCh: make(chan struct{}),
}
}
@ -298,12 +300,8 @@ func (b *Backend) parseAllStaOutput(output string) map[string]*HostapdStation {
// convertStation converts HostapdStation to backend.Station
func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station {
// Get IP address if available from correlation
ip := b.ipByMAC[mac]
// Attempt hostname resolution if we have an IP
hostname := ""
// TODO: Could do reverse DNS lookup here if needed
hostname := b.hostnameByMAC[mac]
return backend.Station{
MAC: mac,
@ -317,27 +315,34 @@ func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station
}
}
// UpdateIPMapping updates the MAC -> IP mapping from external source (e.g., DHCP)
// This should be called periodically to correlate hostapd stations with IP addresses
func (b *Backend) UpdateIPMapping(macToIP map[string]string) {
// UpdateLeaseInfo updates MAC -> IP and MAC -> hostname mappings from an
// external source (e.g., DHCP lease file). Called periodically by the
// correlator. An empty hostname leaves the existing one in place — DHCP
// leases sometimes drop the hostname between renewals.
func (b *Backend) UpdateLeaseInfo(macToIP, macToHostname map[string]string) {
b.mu.Lock()
defer b.mu.Unlock()
// Track which stations got IP updates
updated := make(map[string]bool)
for mac, ip := range macToIP {
if oldIP, exists := b.ipByMAC[mac]; exists && oldIP != ip {
// IP changed
updated[mac] = true
} else if !exists {
// New IP mapping
if oldIP, exists := b.ipByMAC[mac]; !exists || oldIP != ip {
updated[mac] = true
}
b.ipByMAC[mac] = ip
}
// Trigger update callbacks for stations that got new/changed IPs
for mac, hostname := range macToHostname {
if hostname == "" {
continue
}
if oldName, exists := b.hostnameByMAC[mac]; !exists || oldName != hostname {
updated[mac] = true
}
b.hostnameByMAC[mac] = hostname
}
// Trigger update callbacks for stations whose info changed
for mac := range updated {
if station, exists := b.stations[mac]; exists {
if cb := b.callbacks.OnStationUpdated; cb != nil {

View file

@ -81,14 +81,20 @@ func (dc *DHCPCorrelator) correlate() {
return
}
// Build MAC -> IP mapping
// Build MAC -> IP and MAC -> hostname mappings. Hostnames live in the
// dhcpd "client-hostname" field and let us label devices instead of
// falling back to bare MAC addresses.
macToIP := make(map[string]string)
macToHostname := make(map[string]string)
for _, lease := range leases {
macToIP[lease.MAC] = lease.IP
mac := strings.ToLower(lease.MAC)
macToIP[mac] = lease.IP
if lease.Hostname != "" {
macToHostname[mac] = lease.Hostname
}
}
// Update backend with IP mappings
dc.backend.UpdateIPMapping(macToIP)
dc.backend.UpdateLeaseInfo(macToIP, macToHostname)
}
// parseDHCPLeases reads and parses DHCP lease file