close
Skip to content

Fix: allow host names with tapo plugs#29768

Draft
insomniacslk wants to merge 1 commit into
evcc-io:masterfrom
insomniacslk:tapo_allow_hostname
Draft

Fix: allow host names with tapo plugs#29768
insomniacslk wants to merge 1 commit into
evcc-io:masterfrom
insomniacslk:tapo_allow_hostname

Conversation

@insomniacslk
Copy link
Copy Markdown

The Tapo plug UI mentions hostnames alongside IP addresses, however when using a host name for a Tapo plug, it returns an error (see screenshot):

Screenshot from 2026-05-08 23-03-17

This patch enables both IP addresses and host names for Tapo plugs. Tested manually with a local, patched instance (see screenshot). Please let me know if there's a better way to test this.

Screenshot from 2026-05-08 23-05-45

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 1 issue, and left some high level feedback:

  • When resolving hostnames, addr = addrs[0] assumes at least one result; consider explicitly handling the len(addrs) == 0 case to avoid a potential panic from an empty resolution result.
  • Consider whether context.Background() is appropriate for LookupNetIP here, or if you should thread through a cancellable context from the caller to avoid potentially hanging DNS lookups.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- When resolving hostnames, `addr = addrs[0]` assumes at least one result; consider explicitly handling the `len(addrs) == 0` case to avoid a potential panic from an empty resolution result.
- Consider whether `context.Background()` is appropriate for `LookupNetIP` here, or if you should thread through a cancellable context from the caller to avoid potentially hanging DNS lookups.

## Individual Comments

### Comment 1
<location path="meter/tapo/connection.go" line_range="37-41" />
<code_context>
 	if err != nil {
-		return nil, fmt.Errorf("invalid ip address: %s", uri)
+		// not an IP address, try to resolve as host name
+		addrs, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip", host)
+		if err != nil {
+			return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
+		}
+		addr = addrs[0]
 	}

</code_context>
<issue_to_address>
**suggestion (bug_risk):** Be explicit about which resolved IP to prefer (IPv4 vs IPv6) and how to choose among multiple results.

`LookupNetIP` with network "ip" may return a mix of IPv4 and IPv6 in unspecified order. If consumers expect a specific family or deterministic behavior, consider filtering to the desired family and/or defining a clear selection strategy instead of always using `addrs[0]`.

Suggested implementation:

```golang
	addr, err := netip.ParseAddr(host)
	if err != nil {
		// not an IP address, try to resolve as host name
		addrs, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip", host)
		if err != nil {
			return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
		}
		if len(addrs) == 0 {
			return nil, fmt.Errorf("hostname %q did not resolve to any IP addresses", host)
		}

		// Prefer IPv4 for deterministic behavior; fall back to the first IPv6 address if no IPv4 is available.
		var chosen net.IPAddr
		for _, a := range addrs {
			if ip4 := a.IP.To4(); ip4 != nil {
				chosen = a
				break
			}
			if chosen.IP == nil {
				chosen = a
			}
		}

		if chosen.IP == nil {
			return nil, fmt.Errorf("hostname %q resolved to unsupported IP addresses", host)
		}

		if addr, ok := netip.AddrFromSlice(chosen.IP); ok {
			// use resolved address
			_ = addr
		} else {
			return nil, fmt.Errorf("failed to convert resolved IP for hostname %q to netip.Addr", host)
		}
	}

```

The replacement block assumes that `addr` is used later in the function. Replace the `_ = addr` line with whatever assignment or usage is appropriate in the surrounding code (for example, if `addr` is a named return value, simply assigning to `addr` is enough and the blank identifier can be removed).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread meter/tapo/connection.go Outdated
Comment on lines +37 to +41
addrs, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip", host)
if err != nil {
return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
addr = addrs[0]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Be explicit about which resolved IP to prefer (IPv4 vs IPv6) and how to choose among multiple results.

LookupNetIP with network "ip" may return a mix of IPv4 and IPv6 in unspecified order. If consumers expect a specific family or deterministic behavior, consider filtering to the desired family and/or defining a clear selection strategy instead of always using addrs[0].

Suggested implementation:

	addr, err := netip.ParseAddr(host)
	if err != nil {
		// not an IP address, try to resolve as host name
		addrs, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip", host)
		if err != nil {
			return nil, fmt.Errorf("failed to resolve hostname %q: %w", host, err)
		}
		if len(addrs) == 0 {
			return nil, fmt.Errorf("hostname %q did not resolve to any IP addresses", host)
		}

		// Prefer IPv4 for deterministic behavior; fall back to the first IPv6 address if no IPv4 is available.
		var chosen net.IPAddr
		for _, a := range addrs {
			if ip4 := a.IP.To4(); ip4 != nil {
				chosen = a
				break
			}
			if chosen.IP == nil {
				chosen = a
			}
		}

		if chosen.IP == nil {
			return nil, fmt.Errorf("hostname %q resolved to unsupported IP addresses", host)
		}

		if addr, ok := netip.AddrFromSlice(chosen.IP); ok {
			// use resolved address
			_ = addr
		} else {
			return nil, fmt.Errorf("failed to convert resolved IP for hostname %q to netip.Addr", host)
		}
	}

The replacement block assumes that addr is used later in the function. Replace the _ = addr line with whatever assignment or usage is appropriate in the surrounding code (for example, if addr is a named return value, simply assigning to addr is enough and the blank identifier can be removed).

@andig
Copy link
Copy Markdown
Member

andig commented May 9, 2026

Not sure I follow. Why does Tapo need IP addresses in the first place (didn‘t look at the code). Is this even required?

@andig andig added the devices Specific device support label May 9, 2026
@insomniacslk
Copy link
Copy Markdown
Author

insomniacslk commented May 9, 2026 via email

@andig
Copy link
Copy Markdown
Member

andig commented May 9, 2026

The insomniacslk/tapo library uses netip.Addr, which represents an IP address.

It does, but why? Looking at the code all it does is converting it to string to build a URL. The real solution would thus be to use hostname throughout where hostname might also be an ip.

@andig andig marked this pull request as draft May 9, 2026 09:41
@insomniacslk
Copy link
Copy Markdown
Author

That's a good point. I'll move the logic into the tapo library and adjust the change here to stop parsing the Addr

@insomniacslk
Copy link
Copy Markdown
Author

insomniacslk commented May 9, 2026

ah, that would require an API change. tapo.NewPlug returns just *tapo.Plug, not an error, so we probably need a new wrapper

@insomniacslk
Copy link
Copy Markdown
Author

insomniacslk commented May 9, 2026

@andig there's an extra problem with that change in the tapo library. Right now if we reconnect (via tapo.Plug.Handshake) to refresh the credentials, the plug would use the cached netip.Addr. But if the hostname is changed to point to a different IP address, we need to re-resolve it.
I am not sure this belongs to the tapo library, but rather to the caller. The best I can think of if you really don't want the DNS resolution in evcc, is to provide some higher level wrappers in the tapo library, and clarify that if they want to be resilient to DNS changes, it's up to the caller to call the wrapper with the DNS resolution again. Any thoughts?

@andig
Copy link
Copy Markdown
Member

andig commented May 9, 2026

But if the hostname is changed to point to a different IP address, we need to re-resolve it.

How likely is that to happen? I'd say not at all...

@andig
Copy link
Copy Markdown
Member

andig commented May 9, 2026

Any why not just cache the hostname instead of the ip?

@andig
Copy link
Copy Markdown
Member

andig commented May 9, 2026

anyway, that's not an evcc concern ;)

@insomniacslk
Copy link
Copy Markdown
Author

How likely is that to happen? I'd say not at all...

in my network, Tapo plugs sometimes get a different IP from DHCP, and the DNS record follows that via other automation. That's not infrequent that IPs change, it's less frequent that people use hostnames instead of raw IP addresses

Any why not just cache the hostname instead of the ip?

because it breaks the guarantee that a tapo.Plug always reconnects to the same device. Using a wrapper, or doing it in the caller, is explicit and avoids surprises (like trying to use another device or worse, turning on and off another tapo plug)

anyway, that's not an evcc concern ;)

why not though? I see the grey area here - it's somewhere in between the library and the caller. But if evcc allows using a hostname (which right now is implied in the UI but doesn't work), it's not out of scope to take care of the DNS resolution with that single call to LookupNetIP

@andig
Copy link
Copy Markdown
Member

andig commented May 9, 2026

in my network, Tapo plugs sometimes get a different IP from DHCP

I cant say why that is but it sounds broken. none of the other devices we have do any ip fiddling and really shouldn't. It's your library but imho this is fixing a problem that doesn't have to do with Tapo in the first place.

@insomniacslk
Copy link
Copy Markdown
Author

well, DHCP is not static by design, it's not just my network where IP addresses can change for a device. Another scenario is where I have replaced a tapo plug, which would get a different IP.

Anyway I've added a new wrapper NewPlugFromString here: insomniacslk/tapo#6 . Would you be OK with integrating this new function in evcc instead of tapo.NewPlug ?

@andig
Copy link
Copy Markdown
Member

andig commented May 9, 2026

Any well-behaved router will assign the same ip to the same device again and again. But that's beside the point. If the hostname is stable that doesn't matter at all. Just use hostnames everywhere.

@andig
Copy link
Copy Markdown
Member

andig commented May 9, 2026

Anyway I've added a new wrapper NewPlugFromString here: insomniacslk/tapo#6 . Would you be OK with integrating this new function in evcc instead of tapo.NewPlug ?

Probably. But I really don't see why you'd need ip addresses at all. No other device does that and all of them depend on DHCP.

@insomniacslk insomniacslk force-pushed the tapo_allow_hostname branch from d5a6890 to 2cdfbdd Compare May 10, 2026 20:08
Signed-off-by: Andrea Barberio <insomniac@slackware.it>
@insomniacslk insomniacslk force-pushed the tapo_allow_hostname branch from 2cdfbdd to baa9bd2 Compare May 10, 2026 20:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

devices Specific device support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants