Skip to content

fix(device): make auto-detection best-effort across candidates#70

Merged
wizzomafizzo merged 4 commits into
mainfrom
fix/autodetect-best-effort
Apr 11, 2026
Merged

fix(device): make auto-detection best-effort across candidates#70
wizzomafizzo merged 4 commits into
mainfrom
fix/autodetect-best-effort

Conversation

@wizzomafizzo

@wizzomafizzo wizzomafizzo commented Apr 11, 2026

Copy link
Copy Markdown
Member

Summary

ConnectDevice's auto-detect path picked devices[0] from whatever order the parallel detectors returned, handed it to the transport factory, and committed to that single attempt. If the first candidate was an empty I2C bus, the SAM configuration failure in setupDevice killed the entire connection attempt — even when a perfectly good UART port had also been detected.

Real-world symptom: on a host where the only PN532 is on UART, cmd/reader -write "..." crashed with:

2026/04/11 15:25:49 opening gpio chip /dev/gpiochip0 failed. error: open /dev/gpiochip0: permission denied
Error: failed to connect to PN532 device: failed to initialize device: SAM configuration failed:
SAM configuration command failed: failed to send I2C frame: sysfs-i2c: input/output error

The gpiochip0 line is periph.io's gpioioctl driver logging during host.Init(), which every I2C (and SPI) transport calls on New(). The connection failure is the real bug — the warning is just the noise that made it obvious something was wrong.

Changes

Refactor the auto-detect path so detection results are treated as candidates, not as the answer:

  • Split ConnectDevice into connectManual (explicit path + retry loop) and connectAutoDetected (iterate detected candidates).
  • connectAutoDetected loops over every detected device, calling a new tryAutoDetectedCandidate helper that creates the transport, runs setupDevice, and closes the transport on failure so nothing leaks. The first success is returned immediately.
  • Per-candidate failures go to the session log via Debugf — not propagated to the caller. Only if every candidate fails do we surface an aggregated error naming the total candidate count and joining each per-candidate reason with errors.Join.
  • runAutoDetection extracts the detector-dispatch-and-normalise logic out of the iteration loop so "no devices found" stays a distinct, clean error.
  • setupDeviceWithRetry's autoDetect short-circuit is removed. Only connectManual calls it now; each auto-detect candidate gets exactly one SAM attempt inside tryAutoDetectedCandidate, which preserves the existing "auto-detection bypasses retry logic" contract while still allowing fallback across candidates.

Candidate ordering (second commit): before iterating, stable-sort the detected devices so UART comes before SPI comes before I2C. The reason is narrowly practical — host.Init() is only called when the library actually constructs an I2C or SPI transport, and on desktop Linux that call emits a /dev/gpiochip0 permission-denied warning from gpioioctl for any user who isn't in the gpio group. With UART tried first, a working UART PN532 makes us return before i2c.New() ever runs, so the warning stays silent in the common case. This is not a confidence sort — any working candidate still wins — it's a transport-kind preference aimed at killing the periph.io noise.

What this does not do

  • No confidence sorting inside a transport kind. Order within a group (multiple UART candidates, multiple I2C candidates) is preserved from whatever the detector returned.
  • No filtering of which transports to probe. The library stays neutral; cmd/reader still registers all three detectors via blank imports. A --transports flag is still a reasonable follow-up for users who want to skip I2C/SPI entirely, but it isn't needed once UART-first ordering lands.
  • Does not silence periph.io's log directly. We avoid it by not calling host.Init() in the common path; if the user has no UART PN532 and we do fall through to I2C/SPI, the warning still appears. That's a separate, narrower problem.

Test plan

  • make test — full suite passes with race detection
  • make lint — 0 issues
  • make check — full pre-commit check passes
  • TestConnectDevice_AutoDetect_FallsBackOnInitFailure: two candidates, first returns a SAM-config-failing transport, second returns a working one. Asserts the working device is returned, both factories were called, the failing transport is closed, and the working transport is still open.
  • TestConnectDevice_AutoDetect_AggregatesWhenAllFail: two failing candidates. Asserts the error contains \"all 2 auto-detected candidate(s) failed\", both candidates were tried, and both transports are closed.
  • TestConnectDevice_AutoDetect_PrefersUARTOverI2C: detector returns {i2c, spi, uart} in that order, working UART mock is last. Asserts the factory is called exactly once and only for the UART candidate — neither the I2C nor the SPI transport is ever constructed.
  • TestSortCandidatesByTransportPreference: unit test locking in uart < spi < i2c < unknown and stable ordering within each group (case-insensitive match on the Transport field).
  • Existing TestConnectDevice_WithConnectionRetries (manual path + retry, auto-detect bypasses retry) still passes unchanged — auto-detect still gets exactly one SAM attempt per candidate.
  • Manual validation with go run cmd/reader -write "..." on a host with 22 empty I2C buses + 1 UART PN532: UART is tried first and succeeds; no I2C transport is ever constructed and the gpiochip warning no longer appears in the debug log.

Summary by CodeRabbit

  • Refactor

    • Split connection flows into manual vs auto-detection with prioritized transport ordering (UART → SPI → I2C), consolidated aggregated errors when all candidates fail, and clearer error messages including attempted paths; failed transports are closed promptly. Retry behavior applies only to manual connections.
  • Tests

    • Added tests covering auto-detection candidate iteration, transport preference ordering, error aggregation, and proper transport open/close behavior.

ConnectDevice's auto-detect path picked devices[0] from whatever order
the parallel detectors returned, handed it to the transport factory,
and committed to that single attempt. If the first candidate was a
Low-confidence I2C bus with no PN532 actually wired up, the SAM
configuration failure in setupDevice killed the entire connection
attempt — even when a perfectly good UART port had also been detected.

Real-world impact: on a host that doesn't currently have an /dev/tty*
with a PN532 dongle, cmd/reader now tries whatever candidate the
detector registry hands over first. If that's an I2C bus, periph.io's
host.Init() logs a gpioioctl permission-denied warning and then the
I2C transport's SAM command fails with sysfs-i2c I/O error, and the
whole reader dies. A retry loop wouldn't help — the bus is just empty.

Refactor the auto-detect code path so it treats detection results as
candidates, not as the answer:

- Split ConnectDevice into connectManual (explicit path + retry loop)
  and connectAutoDetected (iterate detected candidates).
- connectAutoDetected loops over every detected device, calling a new
  tryAutoDetectedCandidate helper that creates the transport, runs
  setupDevice, and closes the transport on failure. The first success
  is returned immediately.
- Per-candidate failures go to the session log via Debugf. Only if
  every candidate fails do we surface an aggregated error naming the
  total candidate count and joining each per-candidate reason via
  errors.Join.
- runAutoDetection pulls the detector-dispatch-and-normalise logic out
  of the iteration loop so "no devices found" stays a distinct error.

setupDeviceWithRetry no longer contains the autoDetect short-circuit:
only connectManual calls it now, so the branch was dead code. The
existing "auto-detection bypasses retry" test still passes because the
new path simply doesn't call setupDeviceWithRetry at all — each
candidate gets exactly one SAM attempt inside tryAutoDetectedCandidate.

Tests added:
- TestConnectDevice_AutoDetect_FallsBackOnInitFailure: two candidates,
  first returns a failing transport whose SAM command fails, second
  returns a working transport. Asserts the working candidate is
  returned, both factories were called, the failing transport was
  closed, and the working transport is still open.
- TestConnectDevice_AutoDetect_AggregatesWhenAllFail: two failing
  candidates, asserts ConnectDevice returns an error containing
  "all 2 auto-detected candidate(s) failed", both candidates were
  tried, and both transports were closed.
…idates

On desktop Linux, periph.io's host.Init() logs a /dev/gpiochip0
permission-denied warning (from gpioioctl) every time the I2C or SPI
transport is constructed for the first time. That warning is unrelated
to whether a PN532 is actually present — it fires as soon as host.Init
walks its driver registry. Previously, with I2C and UART both
registered as detectors, the parallel-detector race in DetectAll often
put an I2C candidate first in the returned slice, so the fallback loop
in connectAutoDetected would construct an i2c.New() before ever
reaching the UART candidate. Users saw the gpiochip warning on every
reader run even when a UART PN532 was plugged in and working.

Add a transport-kind preference to connectAutoDetected: before iterating
candidates, stable-sort the slice so UART is tried before SPI, and SPI
before I2C. This is not a confidence sort — any working candidate still
wins — it just orders by "which transports can be opened without
emitting spurious periph.io init noise". With UART present, we now
never touch i2c.New() / spi.New() and the gpiochip warning is silent.

sortCandidatesByTransportPreference lives alongside the other
auto-detect helpers. transportPreferenceRank is case-insensitive so
detectors that use different casing conventions sort consistently.

Tests added:
- TestConnectDevice_AutoDetect_PrefersUARTOverI2C: detector returns
  {i2c, spi, uart} in that order, working UART mock last; factory call
  log asserts UART was the only transport constructed.
- TestSortCandidatesByTransportPreference: unit test for the ranking
  (uart < spi < i2c < unknown) and for stable ordering within groups.
@coderabbitai

coderabbitai Bot commented Apr 11, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8fdadb28-d840-4a85-bc67-85df9b9661f2

📥 Commits

Reviewing files that changed from the base of the PR and between 84cd0da and 63f79b3.

📒 Files selected for processing (1)
  • device.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • device.go

📝 Walkthrough
🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main refactoring: auto-detection now iterates through detected device candidates and succeeds on the first working one, rather than failing if the first candidate fails.
Docstring Coverage ✅ Passed Docstring coverage is 81.25% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/autodetect-best-effort

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented Apr 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.45614% with 10 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
device.go 82.45% 6 Missing and 4 partials ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@device.go`:
- Around line 274-277: The refactor made connectManual and the auto-detect path
require injected factories; instead, revert to using the built-in transport
creation when the config fields are nil: in connectManual (and likewise in the
auto-detect path where config.transportDeviceFactory is checked) replace the
current early error on config.transportFactory/config.transportDeviceFactory
being nil with code that falls back to the package's default transport
factory/transport-device-factory (i.e., construct or call the existing internal
default transport creation routine when config.transportFactory or
config.transportDeviceFactory is nil). Ensure applyConnectOptions can still
leave those fields nil and ConnectDevice callers on the documented examples
continue to work by treating the config fields as overrides rather than required
values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f2284898-cefa-4b93-b51b-c12f33fb6298

📥 Commits

Reviewing files that changed from the base of the PR and between fc7d2ab and 84cd0da.

📒 Files selected for processing (2)
  • device.go
  • device_test.go

Comment thread device.go
Comment on lines +274 to +277
func connectManual(ctx context.Context, path string, config *connectConfig) (*Device, error) {
if config.transportFactory == nil {
return nil, errors.New("transport factory not provided")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Restore the default transport creation path.

Both branches now hard-fail unless the caller injects a factory option, but the public examples on Line 187 and Line 191 still call ConnectDevice without one. Because applyConnectOptions leaves both factory fields nil, this refactor turns the documented manual and auto-detect entry points into "transport factory not provided" / "transport device factory not provided" failures. Please keep the built-in transport creation behavior as the default and treat these options as overrides.

Also applies to: 304-305

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@device.go` around lines 274 - 277, The refactor made connectManual and the
auto-detect path require injected factories; instead, revert to using the
built-in transport creation when the config fields are nil: in connectManual
(and likewise in the auto-detect path where config.transportDeviceFactory is
checked) replace the current early error on
config.transportFactory/config.transportDeviceFactory being nil with code that
falls back to the package's default transport factory/transport-device-factory
(i.e., construct or call the existing internal default transport creation
routine when config.transportFactory or config.transportDeviceFactory is nil).
Ensure applyConnectOptions can still leave those fields nil and ConnectDevice
callers on the documented examples continue to work by treating the config
fields as overrides rather than required values.

Two pre-existing problems in the ConnectDevice documentation, surfaced
during review of PR #70:

1. The example snippets were attached to applyConnectOptions (an
   unexported helper) instead of ConnectDevice itself, so godoc showed
   them on the wrong symbol and readers looking at ConnectDevice got no
   example at all.

2. The examples called ConnectDevice with no factory option, e.g.:
       pn532.ConnectDevice("/dev/ttyUSB0")
       pn532.ConnectDevice("", pn532.WithAutoDetection())
   Both have always failed at runtime with "transport factory not
   provided" / "transport device factory not provided" because the
   pn532 package deliberately does not import the transport subpackages
   and therefore cannot ship a default factory. Following the examples
   verbatim left callers confused about why a supposedly working call
   bailed immediately.

Move the docstring to ConnectDevice, explain *why* the factories are
required (transport subpackages are kept separate so consumers don't
pull in transports they don't use), and show real examples for both
the explicit-path and auto-detect paths using uart.New under
WithTransportFactory / WithTransportFromDeviceFactory. Point at
cmd/reader/main.go for the full three-transport dispatch.
@wizzomafizzo
wizzomafizzo merged commit 08ffc79 into main Apr 11, 2026
18 checks passed
@wizzomafizzo
wizzomafizzo deleted the fix/autodetect-best-effort branch April 11, 2026 09:42
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