Skip to content

USB device picker: show connected devices by name#569

Closed
dustinrue wants to merge 18 commits into
mainfrom
feature/545-usb-device-picker
Closed

USB device picker: show connected devices by name#569
dustinrue wants to merge 18 commits into
mainfrom
feature/545-usb-device-picker

Conversation

@dustinrue

Copy link
Copy Markdown
Owner

Closes #545

Summary

  • USBSensor now emits one SensorReading per connected device using the IOKit product name as the label and lowercase hex vvvv:pppp as the key. Devices with duplicate names get the ID appended in parentheses to disambiguate. Watched-but-disconnected keys still emit boolean(false) so the rule engine always has a value to evaluate.

  • CreateRuleView replaces the raw hex text field for the USB sensor with a usbDevicePicker — the same pattern as the Bluetooth and Bonjour pickers:

    • Dropdown lists every currently connected USB device by name with a green/grey connected indicator
    • Selecting a device writes the vendorID:productID key into readingKey automatically
    • A "Device ID" confirmation label shows the resolved key below the picker
    • A "Not connected?" text field below allows manual ID entry for devices not currently plugged in
  • Rule preview now shows the human-readable device name instead of the raw hex key.

Test plan

  • Open Settings → Sensors — USB Device sensor shows connected devices with product names as reading labels
  • Create a rule: USB Device sensor → picker shows connected devices by name
  • Select a device → Device ID label shows the hex key, condition section shows Connected/Not connected
  • Save rule → rule appears in the list with human-readable name in preview
  • Unplug device → rule evaluates to Not connected
  • Plug device back in → rule evaluates to Connected
  • Manual entry: type a hex ID for a device not currently plugged in → rule saves correctly

🤖 Generated with Claude Code

Dustin Rue and others added 18 commits May 17, 2026 20:55
Three-panel layout revised:
- Panel 1 (left, ~1/3): profile list with dot, name, confidence score
- Panel 2 (centre): profile name, threshold slider, exclusive toggle,
  live confidence badge, then rules list with match state and toolbar
- Panel 3 (right): all global actions grouped by On Activate / On Deactivate,
  each with a checkbox to link/unlink to the selected profile — no add/edit here

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each reading row gets a [+] button that opens a QuickCreateRuleView sheet
pre-filled with the sensor/key/value. User picks profile, sets weight,
optionally negates, and saves. New Profile can be created inline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…reate rule

- ActionTypes.swift: add Action and ProfileActionLink SDK types
- SensorTypes.swift: add Hashable + Identifiable to SensorReading/ObservationValue
- AppDatabase.swift: v7 migration adds actions + profileActionLinks tables
- ActionStore.swift: CRUD for Action and ProfileActionLinkStore
- Backend.swift: wire actionStore + profileActionLinkStore
- ProfileActivationManager.swift: execute via profileActionLinks -> actions
- ControlPlaneStore.swift: publish actions/profileActionLinks, add CRUD methods
- PreferencesView.swift: new tab order (Profiles, Actions, Sensors, General), min 860x520
- ProfilesTabView.swift: three-panel HSplitView with ProfileDetailPanel + ProfileActionsPanel
- ProfileDetailView.swift: removed (absorbed into ProfilesTabView)
- ActionsTabView.swift: global action library with CreateOrEditActionView sheet
- QuickCreateRuleView.swift: pre-filled rule creation from a live sensor reading
- SensorsTabView.swift: add [+] button column triggering QuickCreateRuleView sheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ActionConfigForm.swift handles every action type with purpose-built controls:
- Shell Script: Browse button scoped to scripts, optional arguments field
- Open File/App: Browse button (any file or .app)
- Open and Hide: Browse button (.app bundles only)
- Open URL: text field with scheme hint
- Quit Application: picker from installed apps (/Applications + ~/Applications +
  /System/Applications), force-quit toggle
- Speak Text: text field + voice picker populated from NSSpeechSynthesizer,
  grouped by locale, system default option
- Mount Volume: URL field with smb/afp/nfs hint
- Unmount Volume: picker of currently mounted volumes (non-root)
- Desktop Background: image Browse button + All/Main display segmented control
- Toggle WiFi / Prevent Sleep: Enable/Disable segmented control
- Switch Network Location: picker loaded from networksetup -listlocations
- Set Default Printer: picker from NSPrinter.printerNames
- Run Shortcut: picker populated from shortcuts list --show-identifiers,
  auto-fills shortcutName display field on selection
- Set Time Machine Destination: Browse button (directories)
- Lock Keychain / Start Screen Saver / Start Time Machine: no-config message

PathPanelConfig moved to ActionConfigForm.swift (was duplicated in CreateActionView).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sensors that have no enabled rules are now stopped when the settings
window is closed, eliminating background polling from unused sensors.
When settings opens, all sensors start so the user sees live readings
for every sensor while creating rules. Run policy re-applies whenever
rules change so newly-ruleless sensors stop promptly.

Key changes:
- SensorCoordinator: separate register() (no start) from add();
  add startAll() and applyRunPolicy(neededIDs:) methods
- Backend: registerSensor() now calls register() instead of add(),
  adds sensorIDsNeededForRules() and applyRunPolicy(settingsOpen:),
  applies policy after startup and after every rule refresh
- PreferencesWindowController: accepts onOpen/onClose callbacks
- AppDelegate: wires open to startAll, close to applyRunPolicy on backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When cpctl runs 'sensors readings', every registered sensor now calls
refresh() before snapshots are returned, ensuring callers get current
data rather than stale cached values.

A new SensorCoordinator.refreshForQuery() method refreshes all
registered sensors (running or stopped) without firing the snapshot
callback, since this is a read-only inspection path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous implementation only called refresh() on sensors, which is a
no-op on BaseSensor (the default). Stopped sensors also had publishInactive()
called on them, leaving their snapshots empty.

New approach:
- Running sensors: refresh() is called (effective for WiFi, FilePresence)
- Stopped sensors: start() is called so most sensors call refreshSnapshot()
  synchronously and immediately have data; they are then stopped in a
  background task after the caller has collected the snapshot

Sensors with deferred init (BluetoothSensor 2s TCC delay) still show as
inactive on a query, which is acceptable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of having the user pick a reading key from the static list and
then type an SSID by hand, the Wi-Fi sensor now gets its own network
picker in CreateRuleView. When the modal opens (or the Wi-Fi sensor is
selected), a one-shot CoreWLAN scan runs in the background and populates
a dropdown with nearby SSIDs.

Key behaviour:
- readingKey is always forced to "ssid"; comparand = selected SSID name
- The currently connected network is highlighted with a wifi icon and
  "(connected)" label so it is easy to identify
- The scan result also includes the live SSID from the sensor snapshot
  and (when editing) the existing comparand, so the picker always has a
  sensible selection even when a network is not currently nearby
- "Scan again" button lets the user re-scan without reopening the sheet
- Negate hint explains that the flag inverts to "not connected to X"
- operatorID is auto-seeded to "equals" when a network is chosen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the generic Negate toggle + operator picker + comparand field
with a simple two-option radio picker for WiFi SSID rules. The user
sees only 'Connected' or 'Disconnected'; the negate flag is wired
behind the scenes so the data model is unchanged.

- wifiConditionSection: radio picker bound to negate via a computed
  Binding (connected = negate false, disconnected = negate true)
- canSave: WiFi SSID rules skip the operatorID check since equals is
  always seeded automatically when a network is chosen
- rulePreview: shows 'Wi-Fi: Connected to "X"' or 'Disconnected from'
  instead of the raw 'NOT ssid equals' form
- wifiNetworkPicker caption updated to reference the Condition section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…k refresh()

Verification findings:

WiFiSensor - correctly subscribed:
  - CWEventType: ssidDidChange, bssidDidChange, linkDidChange, modeDidChange
  - NotificationCenter mirrors for all four (belt-and-suspenders on macOS 14)
  - refresh() overridden: re-fetches CWInterface and calls refreshSnapshot()

NetworkLinkSensor - correctly subscribed:
  - SCDynamicStore watches State:/Network/Interface/.*/Link (regex pattern)
  - CFRunLoopSource on main run loop fires refreshSnapshot() on any link change
  - refresh() was missing (inherited no-op) -- now overrides to call refreshSnapshot()

Gap found in both: no sleep/wake handling. After sleep, interfaces
re-establish and the SCDynamicStore / CWWiFiClient callbacks will fire
eventually, but there is a stale-data window between wake and the first
hardware event. Fixed by:

  - Backend: subscribes to NSWorkspace.didWakeNotification and calls
    sensorCoordinator.refreshAllSensors() so all sensors re-read current
    hardware state immediately on wake
  - NetworkLinkSensor: override refresh() to call refreshSnapshot() so
    the wake-triggered refreshAllSensors() actually re-reads link state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two bugs found:

1. object: client filter silently drops all CoreWLAN notifications.
   On macOS 12+, CWWiFiClient posts notifications with the CWInterface
   as the object, not the CWWiFiClient itself. Using object: client in
   the NotificationCenter observers meant every SSID/link change was
   silently filtered out. Fixed by changing to object: nil.

2. CWWiFiClient notifications are unreliable on modern macOS.
   Added NWPathMonitor (Network.framework) as a reliable backbone.
   NWPathMonitor fires immediately on start with current state and
   again whenever the WiFi path changes (connect, disconnect, interface
   going down). pathUpdateHandler dispatches to main queue and calls
   refreshSnapshot() so the sensor always reflects reality.

The two mechanisms are complementary:
- NWPathMonitor: reliable for connect/disconnect path changes
- CWWiFiClient notifications (now unfiltered): for SSID roaming and
  other fine-grained events that don't change the network path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…while open

Rename:
- PreferencesView -> SettingsView (file + type)
- PreferencesWindowController -> SettingsWindowController (file + type)
- Window title: 'ControlPlane' -> 'ControlPlane Settings'
- Menu item: 'Preferences...' -> 'Settings...' (Cmd+, shortcut unchanged)
- Selector: openPreferences -> openSettings

Dock icon / Cmd+Tab:
- SettingsWindowController.show() calls NSApp.setActivationPolicy(.regular)
  before makeKeyAndOrderFront so the Dock icon and Cmd+Tab entry are
  present from the very first visible frame
- windowWillClose() reverts to NSApp.setActivationPolicy(.accessory) so
  the app disappears from the Dock and switcher once Settings is closed
- LSUIElement in Info.plist is unchanged; the policy is toggled at runtime

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The flyout was referencing the old ProfileAction model; rewrote
rebuildRunActionsMenu() to join ProfileActionLinks with Actions and
Profiles, and runActionItem(_:) to execute via ProfileActionLink.
Also updated the Combine subscription to observe actions,
profileActionLinks, profiles, and actionTypes together.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Actions without a profile link appear under a Standalone section and run
immediately with a placeholder profile context. Linked actions retain their
profile/trigger grouping. Renames runActionItem to runLinkedActionItem and
adds runStandaloneActionItem.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
One entry per action, sorted by name, with type in parentheses.
No profile/trigger grouping — any action can be run on demand.
Subscription narrowed to store.$actions + store.$actionTypes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
USBSensor now emits one boolean reading per connected device using
the product name as the label and lowercase hex vid:pid as the key.
Duplicate names are disambiguated with the ID in parentheses.
Watched-but-disconnected keys still emit boolean(false) for the rule
engine.

CreateRuleView adds a usbDevicePicker that mirrors the Bluetooth
picker: a live dropdown of connected devices with a green/grey
indicator, a Device ID confirmation label, and a manual fallback
text field for devices not currently plugged in. The rule preview
now shows the human-readable device name instead of the hex key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…oggle

isUSBDeviceRule routes to the same wifiConditionSection so the user
picks Connected or Disconnected; the negate flag is set behind the
scenes. canSave and rulePreviewText updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The USB sensor starts when Settings opens but store.snapshots may not
have been updated yet when CreateRuleView appears. Call refreshSnapshots()
when the USB sensor is selected (onAppear or onChange) so the picker
has current device data, mirroring how WiFi triggers a scan on open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@dustinrue

Copy link
Copy Markdown
Owner Author

Superseded — all changes from this branch were merged directly into feature/525-gui-buildout and are included in PR #550.

@dustinrue dustinrue closed this May 20, 2026
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.

USB Device: show connected devices by name instead of requiring hex vendor/product IDs

1 participant