From 6ee7a70d53fd92b1eb73f81421593db53415bba7 Mon Sep 17 00:00:00 2001 From: mvadu Date: Sat, 8 Aug 2026 04:22:15 +0000 Subject: [PATCH] feat: allow configuring devices with hostname instead of IP Adds an optional host field to device configuration as an alternative to ip, enabling connections via stable mDNS or DNS names when dynamic IPs are used. Changes include: - Updated config validation to require either ip or host, and only validate IP format when explicitly provided - Refactored connection logic to prioritize host over ip for establishing device connections - Adjusted Prometheus metric labels to gracefully handle cases where the IP address is omitted - Expanded README documentation with a configuration fields reference table and revised validation behavior --- README.md | 18 +++++++++-- kasa-exporter.py | 77 ++++++++++++++++++++++++++---------------------- 2 files changed, 56 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index ac04c2b..2f62bed 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,8 @@ global_labels: site: "brampton" devices: - - ip: "192.168.1.100" + - ip: "192.168.1.100" # IP address of the device + # host: "kasa-strip.local" # OR hostname (either ip or host is required) name_override: "Core-Rack-Strip" outlets: 0: @@ -153,6 +154,17 @@ devices: target_app: "proxmox" ``` +### Device Configuration Fields + +Each device entry supports the following fields: + +| Field | Required | Description | +|-------|----------|-------------| +| `ip` | One of `ip` or `host` | IP address of the Kasa device (e.g., `"192.168.1.100"`) | +| `host` | One of `ip` or `host` | Hostname of the Kasa device (e.g., `"kasa-strip.local"`). Useful when devices have dynamic IPs but stable mDNS/DNS names. If both `ip` and `host` are provided, `host` takes precedence for connection. | +| `name_override` | No | Custom name for the device, used in the `device_name` label. Defaults to `kasa_
` if not set. | +| `outlets` | No | Map of outlet index to outlet configuration (name, custom labels) | + ### Configuration Validation (Edge Cases) The exporter validates `config.yaml` at startup and exits with a non-zero code on fatal errors: @@ -163,8 +175,8 @@ The exporter validates `config.yaml` at startup and exits with a non-zero code o | **Malformed YAML** | Fatal error, logs parse details then exits | | **Empty / comment-only config** | Fatal error, exits with descriptive message | | **Missing `devices` section** | Warning logged; exporter continues (no devices to poll) | -| **Device entry missing `ip`** | Fatal error, exits | -| **Invalid IP address format** | Fatal error, exits with details | +| **Device entry missing both `ip` and `host`** | Fatal error, exits | +| **Invalid IP address format** | Fatal error, exits with details (only validated if `ip` is provided) | ### Environment Variables diff --git a/kasa-exporter.py b/kasa-exporter.py index f47228f..bbf3e9f 100644 --- a/kasa-exporter.py +++ b/kasa-exporter.py @@ -46,12 +46,14 @@ def load_config(): logging.warning("No 'devices' section found in config.") for idx, dev in enumerate(devices): ip = dev.get("ip") - if not ip: - raise ValueError(f"Device #{idx} missing required 'ip' field.") - try: - ipaddress.ip_address(ip) - except ValueError: - raise ValueError(f"'{ip}' is not a valid IP address for device #{idx}.") + host = dev.get("host") + if not ip and not host: + raise ValueError(f"Device #{idx} missing required field: either 'ip' or 'host' must be provided.") + if ip: + try: + ipaddress.ip_address(ip) + except ValueError: + raise ValueError(f"'{ip}' is not a valid IP address for device #{idx}.") return config try: @@ -113,35 +115,38 @@ def classify_error(exc): return "unknown", 99 -async def connect_device(ip): +async def connect_device(address): try: - dev = await asyncio.wait_for(SmartDevice.connect(host=ip), timeout=CONNECT_TIMEOUT) + dev = await asyncio.wait_for(SmartDevice.connect(host=address), timeout=CONNECT_TIMEOUT) return dev except (TimeoutError, asyncio.TimeoutError): - logging.warning(f"Connection timed out after {CONNECT_TIMEOUT}s for {ip}") + logging.warning(f"Connection timed out after {CONNECT_TIMEOUT}s for {address}") raise except AttributeError: - dev = SmartStrip(ip) + dev = SmartStrip(address) await asyncio.wait_for(dev.update(), timeout=CONNECT_TIMEOUT) return dev async def poll_device(device_cfg, poll_interval): - ip = device_cfg["ip"] + ip = device_cfg.get("ip", "") + host = device_cfg.get("host", "") + # Use host if provided, otherwise fall back to ip + device_address = host or ip outlet_cfg = device_cfg.get("outlets", {}) - dev_name = device_cfg.get("name_override") or f"kasa_{ip}" + dev_name = device_cfg.get("name_override") or f"kasa_{device_address}" # Initialize gauges for error tracking - GAUGE_DEVICE_REACHABLE.labels(device_ip=ip, device_name=dev_name).set(0) - GAUGE_LAST_ERROR_CODE.labels(device_ip=ip, device_name=dev_name, error_type='none').set(0) + GAUGE_DEVICE_REACHABLE.labels(device_ip=ip or "", device_name=dev_name).set(0) + GAUGE_LAST_ERROR_CODE.labels(device_ip=ip or "", device_name=dev_name, error_type='none').set(0) while True: try: - dev = await connect_device(ip) + dev = await connect_device(device_address) # Update reachable status on success - GAUGE_DEVICE_REACHABLE.labels(device_ip=ip, device_name=dev_name).set(1) - GAUGE_LAST_ERROR_CODE.labels(device_ip=ip, device_name=dev_name, error_type='none').set(0) + GAUGE_DEVICE_REACHABLE.labels(device_ip=ip or "", device_name=dev_name).set(1) + GAUGE_LAST_ERROR_CODE.labels(device_ip=ip or "", device_name=dev_name, error_type='none').set(0) # Build global label dict gl = {k: global_labels.get(k, "") for k in GLOBAL_LABEL_KEYS} @@ -149,51 +154,51 @@ async def poll_device(device_cfg, poll_interval): # ─── System Metadata ────────────────────────────────────── try: GAUGE_INFO.labels( - device_ip=ip, device_name=dev_name, + device_ip=ip or "", device_name=dev_name, model=dev.alias, firmware=getattr(dev, 'firmware', {}), hardware=getattr(dev, 'hardware', {}), mac=getattr(dev, 'mac_address', '') or '' ).set(1) except Exception as e: - logging.warning(f"Could not set metadata for {ip}: {e}") + logging.warning(f"Could not set metadata for {device_address}: {e}") # ─── Device-level gauges ────────────────────────────────── try: if hasattr(dev, 'rssi'): - GAUGE_RSSI.labels(device_ip=ip, device_name=dev_name, **gl).set(dev.rssi) + GAUGE_RSSI.labels(device_ip=ip or "", device_name=dev_name, **gl).set(dev.rssi) else: sys_info = dev.system_information or {} rssi_val = int(sys_info.get('rssi', 0)) if sys_info else None if rssi_val is not None: - GAUGE_RSSI.labels(device_ip=ip, device_name=dev_name, **gl).set(rssi_val) + GAUGE_RSSI.labels(device_ip=ip or "", device_name=dev_name, **gl).set(rssi_val) uptime_val = getattr(dev, 'uptime', None) or 0 try: if isinstance(uptime_val, (int, float)) and uptime_val > 0: - GAUGE_UPTIME.labels(device_ip=ip, device_name=dev_name, **gl).set(uptime_val) + GAUGE_UPTIME.labels(device_ip=ip or "", device_name=dev_name, **gl).set(uptime_val) except Exception: pass except Exception as e: - logging.warning(f"Could not read rssi/uptime for {ip}: {e}") + logging.warning(f"Could not read rssi/uptime for {device_address}: {e}") # ─── Aggregate (root device) metrics ────────────────────── try: em = dev.emeter_realtime if em: - GAUGE_DEV_POWER.labels(device_ip=ip, device_name=dev_name, **gl).set(em.get('power_mw', 0) / 1000.0) - GAUGE_DEV_VOLTAGE.labels(device_ip=ip, device_name=dev_name, **gl).set(em.get('voltage_mv', 0) / 1000.0) - GAUGE_DEV_CURRENT.labels(device_ip=ip, device_name=dev_name, **gl).set(em.get('current_ma', 0) / 1000.0) + GAUGE_DEV_POWER.labels(device_ip=ip or "", device_name=dev_name, **gl).set(em.get('power_mw', 0) / 1000.0) + GAUGE_DEV_VOLTAGE.labels(device_ip=ip or "", device_name=dev_name, **gl).set(em.get('voltage_mv', 0) / 1000.0) + GAUGE_DEV_CURRENT.labels(device_ip=ip or "", device_name=dev_name, **gl).set(em.get('current_ma', 0) / 1000.0) except Exception as e: - logging.warning(f"Could not read aggregate emeter for {ip}: {e}") + logging.warning(f"Could not read aggregate emeter for {device_address}: {e}") # Cumulative energy (total) try: total_em = dev.emeter_statistics if total_em: - GAUGE_DEV_ENERGY.labels(device_ip=ip, device_name=dev_name, **gl).set(total_em.get('total', 0)) + GAUGE_DEV_ENERGY.labels(device_ip=ip or "", device_name=dev_name, **gl).set(total_em.get('total', 0)) except Exception as e: - logging.warning(f"Could not read cumulative emeter for {ip}: {e}") + logging.warning(f"Could not read cumulative emeter for {device_address}: {e}") # ─── Per-outlet metrics (child plugs) ───────────────────── children = getattr(dev, 'children', []) @@ -203,13 +208,13 @@ async def poll_device(device_cfg, poll_interval): o_cfg = outlet_cfg.get(str(idx_val), {}) o_name = o_cfg.get("name", f"Outlet_{idx_val}") custom = {k: o_cfg.get("labels", {}).get(k, global_labels.get(k, "")) for k in OUTLET_CUSTOM_LABEL_KEYS} - out_labels = dict(device_ip=ip, device_name=dev_name, outlet_index=idx_val, outlet_name=o_name, **custom) + out_labels = dict(device_ip=ip or "", device_name=dev_name, outlet_index=idx_val, outlet_name=o_name, **custom) # On/Off state try: GAUGE_STATE.labels(**out_labels).set(1 if child.is_on else 0) except Exception as e: - logging.warning(f"Could not read state for child {idx_val} on {ip}: {e}") + logging.warning(f"Could not read state for child {idx_val} on {device_address}: {e}") # Child real-time emeter try: @@ -219,7 +224,7 @@ async def poll_device(device_cfg, poll_interval): GAUGE_VOLTAGE.labels(**out_labels).set(c_em.get('voltage_mv', 0) / 1000.0) GAUGE_CURRENT.labels(**out_labels).set(c_em.get('current_ma', 0) / 1000.0) except Exception as e: - logging.warning(f"Could not read emeter for child {idx_val} on {ip}: {e}") + logging.warning(f"Could not read emeter for child {idx_val} on {device_address}: {e}") # Child cumulative energy try: @@ -227,14 +232,14 @@ async def poll_device(device_cfg, poll_interval): if c_total: GAUGE_ENERGY.labels(**out_labels).set(c_total.get('total', 0)) except Exception as e: - logging.warning(f"Could not read statistics for child {idx_val} on {ip}: {e}") + logging.warning(f"Could not read statistics for child {idx_val} on {device_address}: {e}") except Exception as e: # ─── Edge case: any connection/poll error ──────────────── err_type, err_code = classify_error(e) - GAUGE_DEVICE_REACHABLE.labels(device_ip=ip, device_name=dev_name).set(0) - GAUGE_LAST_ERROR_CODE.labels(device_ip=ip, device_name=dev_name, error_type=err_type).set(err_code) - logging.error(f"Error polling {ip}: [{err_type}] {e}") + GAUGE_DEVICE_REACHABLE.labels(device_ip=ip or "", device_name=dev_name).set(0) + GAUGE_LAST_ERROR_CODE.labels(device_ip=ip or "", device_name=dev_name, error_type=err_type).set(err_code) + logging.error(f"Error polling {device_address}: [{err_type}] {e}") await asyncio.sleep(poll_interval)