diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..54a5800 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs on the same ref (e.g. rapid pushes to a PR). +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-test: + name: Build & test (macOS) + runs-on: macos-15 + defaults: + run: + working-directory: driver + steps: + - uses: actions/checkout@v4 + + - name: Swift version + run: swift --version + + # Builds every product, including the `nib` app target (AppKit/CoreGraphics), + # so main.swift compile errors are caught, not just the NibCore library. + - name: Build + run: swift build + + # NibCore unit tests + the data-driven ProfileConformanceTests, which validate + # each DeviceProfile against its real captured fixtures (see docs/ADDING-A-DEVICE.md). + - name: Test + run: swift test diff --git a/README.md b/README.md index 7fc679a..883b483 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,19 @@ # Nib -**Nib** is an independent, open-source macOS driver for the **Cintiq Pro 24 Touch -(model DTH2420)** pen display. It was built from scratch — via clean-room reverse -engineering — because Wacom's drivers frequently break on new macOS releases. This one -was created specifically because the Wacom drivers **do not work on the macOS 27 -developer beta**, leaving the tablet unusable. No dependency on OpenTabletDriver or any +**Nib** is an independent, open-source macOS driver for **Wacom pen displays**, built from +scratch — via clean-room reverse engineering — because Wacom's drivers frequently break on +new macOS releases. This one was created specifically because the Wacom drivers **do not +work on the macOS 27 developer beta**, leaving the tablet unusable. Its reference device is +the **Cintiq Pro 24 Touch (model DTH2420)** — the model it is built on and verified with — +but all model-specific details live in swappable **device profiles**, so other Wacom +tablets can be added as data, not new decode code. No dependency on OpenTabletDriver or any vendor software at runtime. -**Working today:** pen (position with in-app calibration, an adjustable pressure curve, -tilt, remappable side buttons), multi-touch (2-finger scroll, tap-to-click, 3-finger -swipe, palm rejection), a menu-bar app and a full preferences window, running as a -persistent per-user LaunchAgent. +**Working today:** automatic detection of the connected tablet with hotplug re-resolution; +pen (position with in-app calibration, an adjustable pressure curve, tilt, remappable side +buttons), multi-touch (2-finger scroll, tap-to-click, 3-finger swipe, palm rejection), a +menu-bar app and a full preferences window, running as a persistent per-user LaunchAgent. ## Screenshots @@ -28,6 +30,13 @@ In-app calibration overlay — tap the two crosshairs with the pen: Calibration overlay +## Supported devices +- **Wacom Cintiq Pro 24 Touch (DTH2420)** — the reference device, fully verified. + +Nib resolves the connected tablet at runtime from a set of device profiles and re-resolves +on hotplug. Other Wacom models can be added from a capture bundle **without shipping +hardware** — see `docs/ADDING-A-DEVICE.md`. + ## Layout - `driver/` — the Swift package (`nib` executable + `NibCore` library). See `driver/README.md` (usage) and `driver/INSTALL.md` (persistent install + code signing). @@ -35,12 +44,16 @@ In-app calibration overlay — tap the two crosshairs with the pen: report 0x10, touch report 0x81, pen mode-switch, touch enable) — the clean-room deliverable. - `recon/` — reverse-engineering findings and decompilation notes. - `captures/` — live HID captures (report descriptors) and the `hidinfo` dump tool. +- `docs/ADDING-A-DEVICE.md` — how to add another tablet from a capture bundle (no code in + the decode path; model support is a `DeviceProfile` + regression fixtures). - `ROADMAP.md` — planned work and stretch ideas. ## How it works -1. `IOHIDManager` opens the pen (`056a:0351`) and touch (`056a:0355`) HID interfaces. -2. On attach it sends the enable/mode-switch reports needed to put the tablet into its - native high-resolution mode (recovered during reverse engineering). +1. On launch (and on hotplug) Nib detects the connected tablet from its **device profiles** + and opens that model's pen and touch HID interfaces via `IOHIDManager` (the DTH2420's + are `056a:0351` / `056a:0355`). +2. On attach it sends the active profile's enable/mode-switch reports to put the tablet + into its native high-resolution mode (recovered during reverse engineering). 3. Reports are decoded by pure-Swift parsers (unit-tested against real captured reports). 4. Input is injected via CoreGraphics tablet events (pen) and scroll/key events (touch) — an entirely userspace path, no kernel extension. diff --git a/ROADMAP.md b/ROADMAP.md index 4b4c2f1..82af42e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,15 +3,25 @@ Nib is fully usable today (see the feature list in `README.md` / `driver/README.md`). These are the remaining and possible enhancements. +## Done +- [x] **Device-config abstraction** — all model-specific identity, report layouts, + mode-switch and touch-enable live in a `DeviceProfile`, chosen at runtime from the + connected hardware (with hotplug re-resolution). Adding a model is data, not decode + code. Contribution workflow: `docs/ADDING-A-DEVICE.md`. + ## Planned - [ ] **ExpressKeys / touch-ring** — the tablet's physical buttons and ring (pen reports `0x11` / `0x13`), mapped to shortcuts. - [ ] **Developer ID signing + notarization** — so Nib can be installed on other Macs without setting up a self-signed certificate. +- [ ] **More device profiles** — bring up additional Wacom models from contributed capture + bundles (`docs/ADDING-A-DEVICE.md`). ## Stretch / ideas - [ ] **Safari swipe-navigation** — phased/gesture scroll so a 2-finger horizontal swipe triggers back/forward (currently plain horizontal scroll; a phased attempt felt janky). - [ ] **Pinch-to-zoom** — needs private CoreGraphics gesture events. - [ ] More configurable gesture and pen-button mappings. -- [ ] A device-config abstraction so other Wacom models can be added. +- [ ] **Descriptor auto-parsing** — derive a `DeviceProfile` layout from a submitted HID + report descriptor (offline generator first; runtime parsing only if a generic + "plug-any-Wacom" fallback becomes a goal). diff --git a/docs/ADDING-A-DEVICE.md b/docs/ADDING-A-DEVICE.md new file mode 100644 index 0000000..090d010 --- /dev/null +++ b/docs/ADDING-A-DEVICE.md @@ -0,0 +1,173 @@ +# Adding support for another tablet + +Nib was built for one device (the Cintiq Pro 24 Touch, DTH2420) but is structured so that +**adding a model is data, not new decode code**. Everything model-specific lives in a +`DeviceProfile` (`driver/Sources/NibCore/DeviceProfile.swift`): USB IDs, report byte +layouts, the pen mode-switch, the touch-enable sequence, and the panel size. The parser, +mode-switch, touch-enable and display match all read from the active profile, chosen at +runtime from whatever tablet is plugged in. + +The key consequence: **someone who owns the hardware can contribute a capture, and a +maintainer who does not own it can turn that capture into a tested profile.** This doc is +the workflow for both roles. + +--- + +## What a contribution needs (the "capture bundle") + +If you have a tablet Nib doesn't support yet, you can produce everything needed by running +two tools. A complete bundle is: + +1. **The HID report descriptor dump** — `hidinfo` output for your device. +2. **Labeled raw report captures** — `nib capture` output while you perform specific + gestures, each clearly labeled (see the gesture list below). +3. **Basic device facts** — model name/number, and the native panel resolution in pixels + (for a pen *display*; skip for an opaque tablet). + +Attach these to an issue/PR. That's enough for a maintainer to author the profile and the +regression fixtures — no shipping the hardware. + +--- + +## Step 1 — capture the descriptor + +Build the little enumerator (once) and dump descriptors for all connected Wacom devices: + +``` +cd captures +clang -o hidinfo hidinfo.c -framework IOKit -framework CoreFoundation +./hidinfo > my-device-descriptors.txt +``` + +This lists each HID interface with its **VID/PID/usagePage/usage** and the raw report +descriptor bytes. Identify the **pen** interface (a digitizer, usagePage `0x0d` or a +vendor page with a high-res coordinate report) and the **touch** interface (a +Precision-Touchpad-style digitizer). Note both PIDs. + +## Step 2 — capture labeled gestures + +`nib capture` opens every connected interface of the known vendors, sends the best-guess +Wacom enable sequences, and prints every report as raw hex tagged by PID: + +``` +nib capture > my-device-capture.txt +``` + +Then perform each gesture below **one at a time**, pausing between them, and keep a note of +which lines correspond to which gesture (timestamps or "I did X now" markers help): + +**Pen (find the pen PID's `id=0x..` lines):** +- Pen tip touching the **top-left** corner, then **center**, then **bottom-right** — this + pins down the X/Y byte offsets and their logical max. +- **Light press** and **hard press** at center — pressure offset and its max. +- **Hover** (in range, not touching) and **lift** (fully away) — the status/proximity bits. +- Optional: **tilt** the pen each way; press each **side button**; flip to the **eraser**. + +**Touch (find the touch PID's lines):** +- **One finger** down at a known spot, **two fingers**, **three fingers**. + +> If the pen produces **no** high-res report under `nib capture`, its mode-switch differs +> from the DTH2420's. That's the one part the descriptor can't give you — note it in the +> bundle; it needs a person with the device to help recover (see "What still needs the +> hardware"). + +--- + +## Step 3 — author the profile (maintainer) + +Read the offsets and logical maxima straight out of the descriptor (the DTH2420 worked +example is `spec/DTH2420-protocol.md`), confirm them against the captured gestures, and add +a `DeviceProfile` literal. Template: + +```swift +public extension DeviceProfile { + static let myModel = DeviceProfile( + name: "Vendor Model Name (MODELNO)", + vendorID: 0x____, // from hidinfo + penPID: 0x____, // pen interface PID + touchPID: 0x____, // touch interface PID + pen: PenLayout( + reportID: 0x__, // the high-res pen report's ID + reportLength: __, // full report length in bytes, incl. the ID byte + offsets: PenLayout.Offsets( + status: 1, x: 2, y: 5, pressure: 8, // byte offsets into the full report + tiltX: 10, tiltY: 11, twist: 12, + distance: 16, serial: 17, toolID: 21), + xLogicalMax: _____, // logical max from the descriptor's X usage + yLogicalMax: _____, + pressureLogicalMax: ____), + touch: TouchLayout( + reportID: 0x__, + minReportLength: 64, + fingerRecordSize: 6, // bytes per finger record + trailerSize: 3, // scanTime(2) + contactCount(1) + xLogicalMax: _____, + yLogicalMax: _____), + initSequence: [ // pen mode-switch; [] if the pen needs no poking + FeatureWrite(reportID: 0x02, [0x02, 0x02]), + ], + touchEnable: [ // touch enable; [] if touch works unprompted + OutputWrite(reportID: 0x06, /* 64-byte vendor buffer */ []), + ], + panelSize: CGSize(width: ____, height: ____)) // nil for an opaque tablet +} +``` + +Then register it so runtime detection and the tests pick it up: + +```swift +static let all: [DeviceProfile] = [.dth2420, .myModel] +``` + +## Step 4 — add regression fixtures + +Turn the labeled captures into a fixture bundle in +`driver/Tests/NibCoreTests/DeviceFixtures.swift` and append it to `DeviceFixtures.all`. +Assert only the values you can confidently label (leave the rest `nil`): + +```swift +static let myModel = DeviceFixtureBundle( + profile: .myModel, + pen: [ + PenFixture(name: "center press", hex: "…captured hex…", + tipDown: true, inRange: true, x: 0x____, y: 0x____, pressure: 0x____), + // top-left, bottom-right, hover, lift … + ], + touch: [ + TouchFixture(name: "one finger", hex: "…", contactCount: 1, firstX: 0x____, firstY: 0x____), + ]) +``` + +`ProfileConformanceTests` then validates the new profile automatically — no per-device test +code. Run: + +``` +cd driver && swift test +``` + +Green means the profile decodes real hardware output correctly. This is the check a +maintainer relies on to accept a device they can't physically test. + +## Step 5 — verify on hardware (contributor) + +Build and run (`driver/INSTALL.md`), plug in the device, and confirm from +`~/Library/Logs/nib.log` that Nib logs `now driving ` and that pen tracking, +pressure, and touch behave. Report the result on the PR. + +--- + +## What still needs the hardware + +The descriptor gives the **decode layout** for free. It does **not** give: + +- **The pen mode-switch** (`initSequence`) — some models only emit their high-res report + after a vendor feature write. For the DTH2420 this was recovered by decompiling Wacom's + driver (`recon/`), not from any descriptor. If `nib capture` shows no pen report, this is + why, and recovering it needs someone with the device (try the DTH2420 sequence first; it + covers much of the Wacom family). +- **The touch-enable sequence** (`touchEnable`) — likewise for the multitouch sensor. +- **Final sign-off** — that pen tracking, pressure curve and gestures actually feel right. + +So the split is: maintainers own the abstraction and the profile authoring; contributors +run two capture commands and do the final on-device confirmation. Neither needs to ship the +tablet anywhere. diff --git a/driver/Sources/CNibHID/CNibHID.c b/driver/Sources/CNibHID/CNibHID.c index 3340701..bffbef6 100644 --- a/driver/Sources/CNibHID/CNibHID.c +++ b/driver/Sources/CNibHID/CNibHID.c @@ -15,6 +15,15 @@ struct NibHIDContext { uint8_t inputBuffer[64]; }; +// Read a numeric IOHIDDevice property (e.g. VendorID/ProductID). Returns 0 if absent. +static uint16_t readDeviceID(IOHIDDeviceRef dev, CFStringRef key) { + CFTypeRef p = IOHIDDeviceGetProperty(dev, key); // "get" rule: not owned, no release + int v = 0; + if (p && CFGetTypeID(p) == CFNumberGetTypeID()) + CFNumberGetValue((CFNumberRef)p, kCFNumberIntType, &v); + return (uint16_t)v; +} + static void inputReportCB(void *context, IOReturn result, void *sender, IOHIDReportType type, uint32_t reportID, uint8_t *report, CFIndex reportLength) { @@ -31,14 +40,19 @@ static void matchCB(void *context, IOReturn result, void *sender, IOHIDDeviceRef IOHIDDeviceRegisterInputReportCallback(dev, ctx->inputBuffer, sizeof(ctx->inputBuffer), inputReportCB, ctx); - if (ctx->onAttach) ctx->onAttach(ctx->userContext, ctx->vendorID, ctx->productID); + // Report the device's real IDs (the match filter may be vendor-only, productID = 0). + if (ctx->onAttach) ctx->onAttach(ctx->userContext, + readDeviceID(dev, CFSTR(kIOHIDVendorIDKey)), + readDeviceID(dev, CFSTR(kIOHIDProductIDKey))); } static void removalCB(void *context, IOReturn result, void *sender, IOHIDDeviceRef dev) { - (void)result; (void)sender; (void)dev; + (void)result; (void)sender; NibHIDContext *ctx = (NibHIDContext *)context; + uint16_t vid = readDeviceID(dev, CFSTR(kIOHIDVendorIDKey)); + uint16_t pid = readDeviceID(dev, CFSTR(kIOHIDProductIDKey)); if (ctx->device == dev) ctx->device = NULL; - if (ctx->onDetach) ctx->onDetach(ctx->userContext, ctx->vendorID, ctx->productID); + if (ctx->onDetach) ctx->onDetach(ctx->userContext, vid, pid); } static CFDictionaryRef makeMatch(uint16_t vid, uint16_t pid) { @@ -58,6 +72,39 @@ static CFDictionaryRef makeMatch(uint16_t vid, uint16_t pid) { return d; } +size_t nib_hid_enumerate(uint16_t vendorID, NibHIDDeviceInfo *out, size_t maxOut) { + if (!out || maxOut == 0) return 0; + IOHIDManagerRef mgr = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone); + if (vendorID) { + CFDictionaryRef m = makeMatch(vendorID, 0); + IOHIDManagerSetDeviceMatching(mgr, m); + CFRelease(m); + } else { + IOHIDManagerSetDeviceMatching(mgr, NULL); // match everything + } + IOHIDManagerOpen(mgr, kIOHIDOptionsTypeNone); + CFSetRef devices = IOHIDManagerCopyDevices(mgr); + size_t n = 0; + if (devices) { + CFIndex count = CFSetGetCount(devices); + const void **arr = calloc((size_t)count, sizeof(void *)); + if (arr) { + CFSetGetValues(devices, arr); + for (CFIndex i = 0; i < count && n < maxOut; i++) { + IOHIDDeviceRef dev = (IOHIDDeviceRef)arr[i]; + out[n].vendorID = readDeviceID(dev, CFSTR(kIOHIDVendorIDKey)); + out[n].productID = readDeviceID(dev, CFSTR(kIOHIDProductIDKey)); + n++; + } + free(arr); + } + CFRelease(devices); + } + IOHIDManagerClose(mgr, kIOHIDOptionsTypeNone); + CFRelease(mgr); + return n; +} + NibHIDContext *nib_hid_create(uint16_t vendorID, uint16_t productID, NibHIDReportCallback onReport, NibHIDDeviceCallback onAttach, diff --git a/driver/Sources/CNibHID/include/CNibHID.h b/driver/Sources/CNibHID/include/CNibHID.h index 665001e..3722561 100644 --- a/driver/Sources/CNibHID/include/CNibHID.h +++ b/driver/Sources/CNibHID/include/CNibHID.h @@ -23,6 +23,17 @@ typedef void (*NibHIDDeviceCallback)(void *ctx, uint16_t vendorID, uint16_t prod typedef struct NibHIDContext NibHIDContext; +// A connected HID device's USB identity, as returned by nib_hid_enumerate(). +typedef struct { + uint16_t vendorID; + uint16_t productID; +} NibHIDDeviceInfo; + +// Enumerate currently-connected HID devices matching `vendorID` (pass 0 for any +// vendor). Writes up to `maxOut` entries into `out`; returns the number written. +// Synchronous — opens a throwaway manager, does not require a run loop. +size_t nib_hid_enumerate(uint16_t vendorID, NibHIDDeviceInfo *out, size_t maxOut); + // Create a manager that matches (vendorID, productID). Pass productID = 0 to // match any product for the vendor. Does not open until nib_hid_run(). NibHIDContext *nib_hid_create(uint16_t vendorID, uint16_t productID, diff --git a/driver/Sources/NibCore/DeviceManager.swift b/driver/Sources/NibCore/DeviceManager.swift new file mode 100644 index 0000000..27b4c89 --- /dev/null +++ b/driver/Sources/NibCore/DeviceManager.swift @@ -0,0 +1,122 @@ +import Foundation + +/// Owns the pen + touch HID transports for whatever known tablet is connected and +/// re-resolves the active `DeviceProfile` on hotplug. One running instance can therefore +/// drive whichever supported model is plugged in — and switch if the hardware changes — +/// without restarting the process. +/// +/// Design: a lightweight *watcher* transport per known vendor fires on any attach/detach. +/// Each event triggers `reconcile()`, which re-detects the connected profile and, if it +/// changed, tears down the old pen/touch pipeline and brings up one for the new device. +/// All UI/display concerns stay in the caller via `Handlers`. +public final class DeviceManager { + public struct Handlers { + /// A decoded pen sample from the active device. + public var onPen: (PenSample) -> Void + /// A decoded touch frame from the active device. + public var onTouch: (TouchFrame) -> Void + /// The active profile changed — to a new model, or to `nil` when the last known + /// device was unplugged. Called on the run loop; use it to (re)build display maps. + public var onProfileChange: (DeviceProfile?) -> Void + /// Optional: result of sending a pen ("pen") or touch ("touch") enable sequence. + public var onEnableResult: ((_ interface: String, _ results: [Bool]) -> Void)? + + public init(onPen: @escaping (PenSample) -> Void, + onTouch: @escaping (TouchFrame) -> Void, + onProfileChange: @escaping (DeviceProfile?) -> Void, + onEnableResult: ((String, [Bool]) -> Void)? = nil) { + self.onPen = onPen + self.onTouch = onTouch + self.onProfileChange = onProfileChange + self.onEnableResult = onEnableResult + } + } + + private let handlers: Handlers + private var watchers: [HIDTransport] = [] + private var pen: HIDTransport? + private var touch: HIDTransport? + private var active: DeviceProfile? + + public init(handlers: Handlers) { + self.handlers = handlers + } + + /// The profile currently being driven, if any. + public var activeProfile: DeviceProfile? { active } + + /// Begin watching for supported tablets and bring up whatever is already connected. + /// Schedules on the current run loop; call `CFRunLoopRun()`/`NSApplication.run()` after. + public func start() { + let vendors = Set(DeviceProfile.all.map { $0.vendorID }) + for v in vendors { + let watcher = HIDTransport( + vendorID: v, productID: 0, // any product for this vendor + onAttach: { [weak self] in self?.reconcile() }, + onDetach: { [weak self] in self?.reconcile() }, + onReport: { _, _ in }) // watch-only; no report decoding + watcher.start() + watchers.append(watcher) + } + reconcile() // hardware already present at launch + } + + public func stop() { + watchers.forEach { $0.stop() } + watchers.removeAll() + teardownActive() + active = nil + } + + /// Re-detect the connected profile; swap the pen/touch pipeline if it changed. + private func reconcile() { + let detected = DeviceProfile.detectConnected().first + if detected?.name == active?.name { return } // no change → nothing to do + teardownActive() + active = detected + if let p = detected { bringUp(p) } + handlers.onProfileChange(detected) + } + + private func teardownActive() { + pen?.stop(); pen = nil + touch?.stop(); touch = nil + } + + private func bringUp(_ profile: DeviceProfile) { + // Capture the transports weakly in their own closures: a transport retains its + // onAttach/onReport, so a strong self-capture would cycle and leak the wrapper on + // every hotplug swap (the C ctx is freed by stop(), but the Swift object would not). + var penT: HIDTransport! + penT = HIDTransport( + vendorID: profile.vendorID, productID: profile.penPID, + onAttach: { [weak self, weak penT] in + guard let penT else { return } + let results = penT.applyFeatures(profile.initSequence) + self?.handlers.onEnableResult?("pen", results) + }, + onReport: { [weak self] reportID, payload in + guard reportID == profile.pen.reportID, + let sample = PenReport.parse(payload, profile.pen) else { return } + self?.handlers.onPen(sample) + }) + penT.start() + pen = penT + + var touchT: HIDTransport! + touchT = HIDTransport( + vendorID: profile.vendorID, productID: profile.touchPID, + onAttach: { [weak self, weak touchT] in + guard let touchT else { return } + let results = touchT.applyOutputs(profile.touchEnable) + self?.handlers.onEnableResult?("touch", results) + }, + onReport: { [weak self] reportID, payload in + guard reportID == profile.touch.reportID, + let frame = TouchReport.parse(payload, profile.touch) else { return } + self?.handlers.onTouch(frame) + }) + touchT.start() + touch = touchT + } +} diff --git a/driver/Sources/NibCore/DeviceProfile.swift b/driver/Sources/NibCore/DeviceProfile.swift new file mode 100644 index 0000000..bd11146 --- /dev/null +++ b/driver/Sources/NibCore/DeviceProfile.swift @@ -0,0 +1,203 @@ +import Foundation +import CoreGraphics + +/// Per-device description: everything that differs between one pen display and another, +/// collected in one place so support for a new model is *data*, not code. +/// +/// Today Nib ships a single profile (`.dth2420`). The parsers, mode-switch, touch-enable +/// and display match all read from a profile rather than hardcoded constants, so adding a +/// model means adding a `DeviceProfile` literal (ideally derived from a submitted HID +/// report-descriptor capture — see `ROADMAP.md`), not editing the decode path. +public struct DeviceProfile: Sendable { + /// Human-readable model name (for logs / UI). + public var name: String + /// USB vendor ID (0x056a = Wacom). + public var vendorID: UInt16 + /// Product ID of the pen (digitizer) HID interface. + public var penPID: UInt16 + /// Product ID of the touch HID interface. + public var touchPID: UInt16 + /// Byte layout of the high-resolution pen report. + public var pen: PenLayout + /// Byte layout of the multitouch report. + public var touch: TouchLayout + /// Feature reports that switch the pen into its native high-resolution mode. + public var initSequence: [FeatureWrite] + /// Output reports that enable the multitouch sensor. + public var touchEnable: [OutputWrite] + /// Native panel resolution in pixels, used to locate the tablet's own display for 1:1 + /// pen mapping. `nil` = no built-in display (map to the main display instead). + public var panelSize: CGSize? + + public init(name: String, vendorID: UInt16, penPID: UInt16, touchPID: UInt16, + pen: PenLayout, touch: TouchLayout, + initSequence: [FeatureWrite], touchEnable: [OutputWrite], + panelSize: CGSize?) { + self.name = name + self.vendorID = vendorID + self.penPID = penPID + self.touchPID = touchPID + self.pen = pen + self.touch = touch + self.initSequence = initSequence + self.touchEnable = touchEnable + self.panelSize = panelSize + } +} + +/// A HID SET_FEATURE write. `bytes[0]` is the report-ID byte (Wacom's convention). +public struct FeatureWrite: Sendable { + public var reportID: UInt32 + public var bytes: [UInt8] + public init(reportID: UInt32, _ bytes: [UInt8]) { + self.reportID = reportID; self.bytes = bytes + } +} + +/// A HID output-report write. `bytes[0]` is the report-ID byte. +public struct OutputWrite: Sendable { + public var reportID: UInt32 + public var bytes: [UInt8] + public init(reportID: UInt32, _ bytes: [UInt8]) { + self.reportID = reportID; self.bytes = bytes + } +} + +/// Byte layout of the high-resolution pen report (default: DTH2420 report 0x10). +/// Offsets are into the full report buffer (index 0 = report-ID byte). +public struct PenLayout: Sendable { + public struct Offsets: Sendable { + public var status, x, y, pressure, tiltX, tiltY, twist, distance, serial, toolID: Int + public init(status: Int, x: Int, y: Int, pressure: Int, tiltX: Int, tiltY: Int, + twist: Int, distance: Int, serial: Int, toolID: Int) { + self.status = status; self.x = x; self.y = y; self.pressure = pressure + self.tiltX = tiltX; self.tiltY = tiltY; self.twist = twist + self.distance = distance; self.serial = serial; self.toolID = toolID + } + } + + public var reportID: UInt32 + public var reportLength: Int + public var offsets: Offsets + public var xLogicalMax: Int + public var yLogicalMax: Int + public var pressureLogicalMax: Int + + public init(reportID: UInt32, reportLength: Int, offsets: Offsets, + xLogicalMax: Int, yLogicalMax: Int, pressureLogicalMax: Int) { + self.reportID = reportID; self.reportLength = reportLength; self.offsets = offsets + self.xLogicalMax = xLogicalMax; self.yLogicalMax = yLogicalMax + self.pressureLogicalMax = pressureLogicalMax + } +} + +/// Byte layout of the Windows-Precision-Touchpad-style multitouch report +/// (default: DTH2420 report 0x81). +public struct TouchLayout: Sendable { + public var reportID: UInt32 + /// Minimum full report length (below this, reject). + public var minReportLength: Int + /// Bytes per finger record (TipSwitch+pad, ContactID, X, Y). + public var fingerRecordSize: Int + /// Trailer bytes after the finger records (ScanTime u16 + ContactCount u8). + public var trailerSize: Int + public var xLogicalMax: Int + public var yLogicalMax: Int + + public init(reportID: UInt32, minReportLength: Int, fingerRecordSize: Int, + trailerSize: Int, xLogicalMax: Int, yLogicalMax: Int) { + self.reportID = reportID; self.minReportLength = minReportLength + self.fingerRecordSize = fingerRecordSize; self.trailerSize = trailerSize + self.xLogicalMax = xLogicalMax; self.yLogicalMax = yLogicalMax + } +} + +// MARK: - Wacom Cintiq Pro 24 Touch (DTH2420) + +public extension PenLayout { + /// DTH2420 report 0x10 — offsets confirmed against live captures (see `PenReport`). + static let dth2420 = PenLayout( + reportID: 0x10, + reportLength: 27, + offsets: Offsets(status: 1, x: 2, y: 5, pressure: 8, tiltX: 10, tiltY: 11, + twist: 12, distance: 16, serial: 17, toolID: 21), + xLogicalMax: PenSample.xLogicalMax, + yLogicalMax: PenSample.yLogicalMax, + pressureLogicalMax: PenSample.pressureLogicalMax) +} + +public extension TouchLayout { + /// DTH2420 report 0x81 — confirmed against live 1/2/3-finger captures (see `TouchReport`). + static let dth2420 = TouchLayout( + reportID: 0x81, + minReportLength: 64, + fingerRecordSize: 6, + trailerSize: 3, + xLogicalMax: TouchContact.xLogicalMax, + yLogicalMax: TouchContact.yLogicalMax) +} + +public extension DeviceProfile { + /// Wacom Cintiq Pro 24 Touch (model DTH2420) — the reference device Nib was built on. + /// + /// Mode-switch recovered from `CGD16GraphicsTablet::DeviceStart` + /// (`recon/decompile-CDTH2420.md`); touch-enable from `CHIDInterface::SendConfigCommand` + /// (`recon/decompile-touch-enable.md`). Buffers include the report-ID byte as `bytes[0]`. + static let dth2420 = DeviceProfile( + name: "Wacom Cintiq Pro 24 Touch (DTH2420)", + vendorID: 0x056a, + penPID: 0x0351, + touchPID: 0x0355, + pen: .dth2420, + touch: .dth2420, + initSequence: [ + FeatureWrite(reportID: 0x02, [0x02, 0x02]), // -> native high-res tablet mode + FeatureWrite(reportID: 0x04, [0x04, 0x00]), // -> data-rate mode + ], + touchEnable: [ + OutputWrite(reportID: 0x06, touchConfigBuffer(sub: 0x06, val: 0x02)), // enable multitouch + OutputWrite(reportID: 0x06, touchConfigBuffer(sub: 0x01, val: 0x03)), // HID+touch configure + ], + panelSize: CGSize(width: 3840, height: 2160)) + + /// Build a 64-byte Wacom vendor touch-config OUTPUT report: + /// [0]=0x06 id, [1]=0x06, [2]=0x00, [3]=sub-cmd, [4]=value, rest 0. + private static func touchConfigBuffer(sub: UInt8, val: UInt8) -> [UInt8] { + var b = [UInt8](repeating: 0, count: 64) + b[0] = 0x06; b[1] = 0x06; b[2] = 0x00; b[3] = sub; b[4] = val + return b + } +} + +public extension DeviceProfile { + /// All profiles Nib knows about. Look up an attached device here by (vendorID, PID). + static let all: [DeviceProfile] = [.dth2420] + + /// Find the profile whose pen or touch interface matches this USB identity. + static func match(vendorID: UInt16, productID: UInt16) -> DeviceProfile? { + all.first { $0.vendorID == vendorID && ($0.penPID == productID || $0.touchPID == productID) } + } + + /// Inspect currently-connected HID hardware and return the known profiles present, + /// most-specific first (a profile whose *pen* interface is attached ranks first). + /// Empty if nothing recognized is plugged in. + static func detectConnected() -> [DeviceProfile] { + let vendors = Set(all.map { $0.vendorID }) + let connected = vendors.flatMap { HIDTransport.enumerate(vendorID: $0) } + var seen = Set() + var result: [DeviceProfile] = [] + // Prefer matching on the pen interface, then touch, so we key off the digitizer. + for connectedInfo in connected { + guard let p = match(vendorID: connectedInfo.vendorID, productID: connectedInfo.productID), + p.penPID == connectedInfo.productID, + !seen.contains(p.name) else { continue } + seen.insert(p.name); result.append(p) + } + for connectedInfo in connected { + guard let p = match(vendorID: connectedInfo.vendorID, productID: connectedInfo.productID), + !seen.contains(p.name) else { continue } + seen.insert(p.name); result.append(p) + } + return result + } +} diff --git a/driver/Sources/NibCore/HIDTransport.swift b/driver/Sources/NibCore/HIDTransport.swift index 58fdbe4..5b73971 100644 --- a/driver/Sources/NibCore/HIDTransport.swift +++ b/driver/Sources/NibCore/HIDTransport.swift @@ -7,24 +7,48 @@ public final class HIDTransport { public typealias ReportHandler = (_ reportID: UInt32, _ payload: [UInt8]) -> Void private var ctx: OpaquePointer? + private var selfRef: Unmanaged? // the +1 retain given to the C userContext private let vendorID: UInt16 private let productID: UInt16 private let onReport: ReportHandler private let onAttach: (() -> Void)? + private let onDetach: (() -> Void)? + + /// A connected HID device's USB identity. + public struct DeviceInfo: Equatable, Sendable { + public let vendorID: UInt16 + public let productID: UInt16 + } + + /// Enumerate currently-connected HID devices for `vendorID` (nil = any vendor). + /// Synchronous; safe to call before any transport is opened. + public static func enumerate(vendorID: UInt16? = nil, max: Int = 64) -> [DeviceInfo] { + var buf = [NibHIDDeviceInfo](repeating: NibHIDDeviceInfo(), count: max) + let n = buf.withUnsafeMutableBufferPointer { + nib_hid_enumerate(vendorID ?? 0, $0.baseAddress, $0.count) + } + return buf.prefix(Int(n)).map { DeviceInfo(vendorID: $0.vendorID, productID: $0.productID) } + } public init(vendorID: UInt16, productID: UInt16, onAttach: (() -> Void)? = nil, + onDetach: (() -> Void)? = nil, onReport: @escaping ReportHandler) { self.vendorID = vendorID self.productID = productID self.onAttach = onAttach + self.onDetach = onDetach self.onReport = onReport } /// Open the device and schedule it on the current run loop. /// Call CFRunLoopRun() / dispatchMain() afterwards to receive reports. public func start() { - let selfPtr = Unmanaged.passRetained(self).toOpaque() + // Keep `self` alive for the C callbacks via a +1 retain handed to userContext; + // `stop()` balances it. This matters because transports are now ephemeral (a fresh + // pen/touch pair per hotplug), so an unbalanced retain would leak each swap. + let ref = Unmanaged.passRetained(self) + selfRef = ref ctx = nib_hid_create( vendorID, productID, { userCtx, reportID, report, length in @@ -38,8 +62,12 @@ public final class HIDTransport { let me = Unmanaged.fromOpaque(userCtx).takeUnretainedValue() me.onAttach?() }, - nil, - selfPtr + { userCtx, _, _ in + guard let userCtx else { return } + let me = Unmanaged.fromOpaque(userCtx).takeUnretainedValue() + me.onDetach?() + }, + ref.toOpaque() ) nib_hid_start(ctx) } @@ -62,8 +90,25 @@ public final class HIDTransport { } } + /// Send a profile's feature writes (pen mode-switch); returns per-write success. + @discardableResult + public func applyFeatures(_ writes: [FeatureWrite]) -> [Bool] { + writes.map { sendFeature(reportID: $0.reportID, $0.bytes) } + } + + /// Send a profile's output writes (touch enable); returns per-write success. + @discardableResult + public func applyOutputs(_ writes: [OutputWrite]) -> [Bool] { + writes.map { sendOutput(reportID: $0.reportID, $0.bytes) } + } + public func stop() { if let ctx { nib_hid_stop(ctx); nib_hid_free(ctx) } ctx = nil + // The manager is closed/freed above, so no further callbacks can fire; release the + // retain taken in start(). The caller still holds a reference during this call, so + // self is not deallocated mid-method. Nil-out so a second stop() is a no-op. + selfRef?.release() + selfRef = nil } } diff --git a/driver/Sources/NibCore/PenReport.swift b/driver/Sources/NibCore/PenReport.swift index 6ebf653..7796f01 100644 --- a/driver/Sources/NibCore/PenReport.swift +++ b/driver/Sources/NibCore/PenReport.swift @@ -30,29 +30,17 @@ public struct PenSample: Equatable, Sendable { } public enum PenReport { - /// HID report ID of the high-resolution pen report. - public static let reportID: UInt32 = 0x10 - /// Full report length in bytes, including the report-ID byte at index 0. - public static let reportLength = 27 + /// HID report ID of the reference (DTH2420) high-resolution pen report. + public static var reportID: UInt32 { PenLayout.dth2420.reportID } + /// Full report length of the reference report, including the report-ID byte at index 0. + public static var reportLength: Int { PenLayout.dth2420.reportLength } - // Byte offsets into the full report buffer (index 0 = report ID). Confirmed live. - private enum Off { - static let status = 1 - static let x = 2 // 24-bit LE - static let y = 5 // 24-bit LE - static let pressure = 8 // 16-bit LE - static let tiltX = 10 // int8 - static let tiltY = 11 // int8 - static let twist = 12 // 16-bit LE signed - static let distance = 16 // 8-bit - static let serial = 17 // 32-bit LE - static let toolID = 21 // 32-bit LE - } - - /// Parse a full report-0x10 buffer (as delivered by IOHIDManager, ID byte included). - public static func parse(_ r: [UInt8]) -> PenSample? { - guard r.count >= reportLength, UInt32(r[0]) == reportID else { return nil } - let st = r[Off.status] + /// Parse a full high-resolution pen report (as delivered by IOHIDManager, ID byte + /// included) according to `layout`. Defaults to the DTH2420 layout. + public static func parse(_ r: [UInt8], _ layout: PenLayout = .dth2420) -> PenSample? { + let o = layout.offsets + guard r.count >= layout.reportLength, UInt32(r[0]) == layout.reportID else { return nil } + let st = r[o.status] return PenSample( tipDown: (st & 0x01) != 0, barrelButton: (st & 0x02) != 0, @@ -60,15 +48,15 @@ public enum PenReport { eraser: (st & 0x08) != 0, inverted: (st & 0x10) != 0, inRange: (st & 0x20) != 0, - x: int24LE(r, Off.x), - y: int24LE(r, Off.y), - pressure: Int(r[Off.pressure]) | (Int(r[Off.pressure + 1]) << 8), - tiltX: Int(Int8(bitPattern: r[Off.tiltX])), - tiltY: Int(Int8(bitPattern: r[Off.tiltY])), - twist: Int(Int16(bitPattern: UInt16(r[Off.twist]) | (UInt16(r[Off.twist + 1]) << 8))), - distance: Int(r[Off.distance]), - serial: u32LE(r, Off.serial), - toolID: u32LE(r, Off.toolID) + x: int24LE(r, o.x), + y: int24LE(r, o.y), + pressure: Int(r[o.pressure]) | (Int(r[o.pressure + 1]) << 8), + tiltX: Int(Int8(bitPattern: r[o.tiltX])), + tiltY: Int(Int8(bitPattern: r[o.tiltY])), + twist: Int(Int16(bitPattern: UInt16(r[o.twist]) | (UInt16(r[o.twist + 1]) << 8))), + distance: Int(r[o.distance]), + serial: u32LE(r, o.serial), + toolID: u32LE(r, o.toolID) ) } diff --git a/driver/Sources/NibCore/TouchReport.swift b/driver/Sources/NibCore/TouchReport.swift index 8fe5d1e..eecf5cb 100644 --- a/driver/Sources/NibCore/TouchReport.swift +++ b/driver/Sources/NibCore/TouchReport.swift @@ -19,23 +19,26 @@ public struct TouchFrame: Equatable, Sendable { } public enum TouchReport { - public static let reportID: UInt32 = 0x81 - public static let fingerRecordSize = 6 // tipswitch+pad(1) + contactID(1) + X(2) + Y(2) + /// HID report ID of the reference (DTH2420) multitouch report. + public static var reportID: UInt32 { TouchLayout.dth2420.reportID } + /// Bytes per finger record in the reference report. + public static var fingerRecordSize: Int { TouchLayout.dth2420.fingerRecordSize } - /// Parse a report-0x81 buffer (report-ID byte at index 0). Confirmed against live - /// 1/2/3-finger captures: 64-byte report, + /// Parse a multitouch report (report-ID byte at index 0) according to `layout`. + /// Defaults to the DTH2420 layout. Confirmed against live 1/2/3-finger captures: + /// 64-byte report, /// [0]=id 0x81, [1..]=contactCount contiguous 6-byte finger records, empty slots 0xFF, /// [61..62]=scanTime u16 LE, [63]=contactCount. /// Finger record: [0]=TipSwitch(bit0), [1]=ContactID, [2..3]=X LE, [4..5]=Y LE. - public static func parse(_ r: [UInt8]) -> TouchFrame? { - guard r.count >= 64, UInt32(r[0]) == reportID else { return nil } - let maxSlots = (r.count - 1 - 3) / fingerRecordSize // 10 for the 64-byte report + public static func parse(_ r: [UInt8], _ layout: TouchLayout = .dth2420) -> TouchFrame? { + guard r.count >= layout.minReportLength, UInt32(r[0]) == layout.reportID else { return nil } + let maxSlots = (r.count - 1 - layout.trailerSize) / layout.fingerRecordSize let count = Int(r[r.count - 1]) let n = min(count, maxSlots) var contacts: [TouchContact] = [] contacts.reserveCapacity(n) for i in 0.. CGRect private static let speeds: [(String, Double)] = [("Slow", 0.20), ("Medium", 0.35), ("Fast", 0.55)] - init(config: NibConfig, emitter: EventEmitter, touch: TouchGestureEngine, calibration: CalibrationController) { + init(config: NibConfig, emitter: EventEmitter, touch: TouchGestureEngine, + calibration: CalibrationController, calibrationBounds: @escaping () -> CGRect) { self.config = config self.emitter = emitter self.touch = touch self.calibration = calibration + self.calibrationBounds = calibrationBounds self.item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) self.prefsModel = PrefsModel(config: config) self.prefs = PreferencesWindowController(model: prefsModel, - onRecalibrate: { calibration.begin(bounds: cintiqDisplayBounds()) }) + onRecalibrate: { calibration.begin(bounds: calibrationBounds()) }) super.init() if let button = item.button { button.image = NSImage(systemSymbolName: "hand.draw", accessibilityDescription: "Nib") @@ -160,7 +164,7 @@ final class StatusBarController: NSObject, NSMenuDelegate { NSWorkspace.shared.open(NibConfig.fileURL) } @objc private func recalibrate() { - calibration.begin(bounds: cintiqDisplayBounds()) + calibration.begin(bounds: calibrationBounds()) } /// Restart via launchd: kickstart -k kills this instance and relaunches a fresh one. /// Useful if the driver ever hangs or the tablet stops responding. diff --git a/driver/Sources/nib/main.swift b/driver/Sources/nib/main.swift index dcd5e9a..9b359ae 100644 --- a/driver/Sources/nib/main.swift +++ b/driver/Sources/nib/main.swift @@ -4,74 +4,87 @@ import ApplicationServices import AppKit import NibCore -// DTH2420k0 confirmed USB identity (from live HID enumeration). -let VENDOR_ID: UInt16 = 0x056a -let PEN_PID: UInt16 = 0x0351 -let TOUCH_PID: UInt16 = 0x0355 +// Resolve the device Nib should drive from whatever tablet is actually plugged in; fall +// back to the reference device if nothing recognized is connected. All model-specific +// identity, report layouts, mode-switch and touch-enable live in the profile — see +// NibCore/DeviceProfile.swift. The one-shot modes (log/touchlog/calibrate) resolve once +// via this helper; `run` delegates ongoing resolution + hotplug to DeviceManager. +func detectProfile() -> DeviceProfile { + let detected = DeviceProfile.detectConnected() + if let p = detected.first { + FileHandle.standardError.write("nib: detected \(p.name)\n".data(using: .utf8)!) + return p + } + FileHandle.standardError.write("nib: no known tablet detected, defaulting to \(DeviceProfile.dth2420.name)\n".data(using: .utf8)!) + return .dth2420 +} let args = CommandLine.arguments let mode = args.count > 1 ? args[1] : "run" -/// Locate the Cintiq's own display so the pen maps 1:1 onto the tablet surface. -/// The DTH2420 panel is 3840x2160 native; match on that, fall back to main display. -func cintiqDisplayBounds() -> CGRect { +// The tablet's own display often re-registers with the window server *after* its HID +// interface re-attaches (e.g. on replug), so a mapping computed at attach time can land +// on the wrong screen. Rebuild mappings whenever the display configuration settles. +var onDisplayReconfigured: (() -> Void)? +func displayReconfigCallback(_ display: CGDirectDisplayID, + _ flags: CGDisplayChangeSummaryFlags, + _ userInfo: UnsafeMutableRawPointer?) { + // Fires with .beginConfigurationFlag before the change and again after; act on "after". + if flags.contains(.beginConfigurationFlag) { return } + onDisplayReconfigured?() +} + +/// Locate the tablet's own display so the pen maps 1:1 onto the tablet surface. +/// Match on the profile's native panel resolution; fall back to the main display. +func cintiqDisplayBounds(_ profile: DeviceProfile) -> CGRect { + guard let panel = profile.panelSize else { return CGDisplayBounds(CGMainDisplayID()) } var count: UInt32 = 0 CGGetActiveDisplayList(0, nil, &count) var ids = [CGDirectDisplayID](repeating: 0, count: Int(count)) CGGetActiveDisplayList(count, &ids, &count) for id in ids { - // Match on native pixel resolution (3840x2160); the panel may run HiDPI, - // so CGDisplayBounds returns the point-space rect used for cursor mapping. + // Match on native pixel resolution; the panel may run HiDPI, so CGDisplayBounds + // returns the point-space rect used for cursor mapping. guard let mode = CGDisplayCopyDisplayMode(id) else { continue } - if mode.pixelWidth == 3840 && mode.pixelHeight == 2160 { + if mode.pixelWidth == Int(panel.width) && mode.pixelHeight == Int(panel.height) { let b = CGDisplayBounds(id) - FileHandle.standardError.write("nib: mapping to Cintiq display id=\(id) bounds=\(b)\n".data(using: .utf8)!) + FileHandle.standardError.write("nib: mapping to tablet display id=\(id) bounds=\(b)\n".data(using: .utf8)!) return b } } - FileHandle.standardError.write("nib: Cintiq display not found, using main display\n".data(using: .utf8)!) + FileHandle.standardError.write("nib: tablet display not found, using main display\n".data(using: .utf8)!) return CGDisplayBounds(CGMainDisplayID()) } -// Mode-switch recovered from CGD16GraphicsTablet::DeviceStart (see -// recon/decompile-CDTH2420.md). Buffers include the report-ID byte as data[0]. -// FEATURE 0x02 = [02 02] -> native high-res tablet mode (report 0x10, 8192 pressure) -// FEATURE 0x04 = [04 00] -> data-rate mode -func enableNativeMode(_ t: HIDTransport) { - let okMode = t.sendFeature(reportID: 0x02, [0x02, 0x02]) - let okRate = t.sendFeature(reportID: 0x04, [0x04, 0x00]) - FileHandle.standardError.write( - "nib: mode-switch sent (0x02=\(okMode ? "ok" : "FAIL"), 0x04=\(okRate ? "ok" : "FAIL"))\n" - .data(using: .utf8)!) +// Pen mode-switch: send the profile's feature reports to put the pen into its native +// high-resolution mode (buffers include the report-ID byte as data[0]). +func enableNativeMode(_ t: HIDTransport, _ profile: DeviceProfile) { + let results = zip(profile.initSequence, t.applyFeatures(profile.initSequence)) + let summary = results.map { String(format: "0x%02x=%@", $0.0.reportID, $0.1 ? "ok" : "FAIL") }.joined(separator: ", ") + FileHandle.standardError.write("nib: mode-switch sent (\(summary))\n".data(using: .utf8)!) } -// Touch enable — two 64-byte vendor OUTPUT reports (ID 0x06), recovered from -// CHIDInterface::SendConfigCommand (recon/decompile-touch-enable.md): -// [0]=0x06 id, [1]=0x06, [2]=0x00, [3]=sub-cmd, [4]=value, rest 0. -// sub-cmd 0x06 val 0x02 = enable multitouch; sub-cmd 0x01 val 0x03 = HID+touch configure. -func enableTouch(_ t: HIDTransport) { - func buf(_ sub: UInt8, _ val: UInt8) -> [UInt8] { - var b = [UInt8](repeating: 0, count: 64) - b[0] = 0x06; b[1] = 0x06; b[2] = 0x00; b[3] = sub; b[4] = val - return b - } - let okMT = t.sendOutput(reportID: 0x06, buf(0x06, 0x02)) - let okCfg = t.sendOutput(reportID: 0x06, buf(0x01, 0x03)) - FileHandle.standardError.write( - "nib: touch-enable sent (multitouch=\(okMT ? "ok" : "FAIL"), configure=\(okCfg ? "ok" : "FAIL"))\n" - .data(using: .utf8)!) +// Touch enable: send the profile's vendor OUTPUT reports to enable the multitouch sensor. +func enableTouch(_ t: HIDTransport, _ profile: DeviceProfile) { + // Label by index + report ID so the log stays correct for any profile's sequence, + // not just the DTH2420's specific two-write enable. + let summary = zip(profile.touchEnable, t.applyOutputs(profile.touchEnable)).enumerated() + .map { String(format: "[%d] 0x%02x=%@", $0.offset, $0.element.0.reportID, $0.element.1 ? "ok" : "FAIL") } + .joined(separator: ", ") + FileHandle.standardError.write("nib: touch-enable sent (\(summary))\n".data(using: .utf8)!) } switch mode { case "log": // Confirm report-0x10 field offsets against real gestures. FileHandle.standardError.write("nib: logging pen reports (Ctrl-C to stop)\n".data(using: .utf8)!) + let profile = detectProfile() var transport: HIDTransport! transport = HIDTransport( - vendorID: VENDOR_ID, productID: PEN_PID, - onAttach: { enableNativeMode(transport) }, + vendorID: profile.vendorID, productID: profile.penPID, + onAttach: { enableNativeMode(transport, profile) }, onReport: { reportID, payload in - guard reportID == PenReport.reportID, let s = PenReport.parse(payload) else { return } + guard reportID == profile.pen.reportID, let s = PenReport.parse(payload, profile.pen) else { return } let line = String(format: "tiltX=%4d tiltY=%4d | tip=%d barrel=%d barrel2=%d eraser=%d prox=%d | x=%6d y=%6d p=%4d\n", s.tiltX, s.tiltY, s.tipDown ? 1 : 0, s.barrelButton ? 1 : 0, s.secondaryBarrel ? 1 : 0, @@ -84,12 +97,13 @@ case "log": case "touchlog": // Raw-hex dump of the touch interface (PID 0x0355) to decode the finger report layout. FileHandle.standardError.write("nib: logging TOUCH reports (Ctrl-C to stop)\n".data(using: .utf8)!) + let profile = detectProfile() var touchT: HIDTransport! touchT = HIDTransport( - vendorID: VENDOR_ID, productID: TOUCH_PID, + vendorID: profile.vendorID, productID: profile.touchPID, onAttach: { FileHandle.standardError.write("nib: TOUCH device opened; sending enable…\n".data(using: .utf8)!) - enableTouch(touchT) + enableTouch(touchT, profile) }, onReport: { reportID, payload in let hex = payload.map { String(format: "%02x", $0) }.joined(separator: " ") @@ -102,7 +116,8 @@ case "calibrate": // Two-point calibration: warp the cursor to a target, the user aligns the pen tip // and holds; we capture the raw pen coords, then derive the pen active-range that // spans the display and save it. Does NOT emit pen events (cursor stays on target). - let bounds = cintiqDisplayBounds() + let profile = detectProfile() + let bounds = cintiqDisplayBounds(profile) func cerr(_ s: String) { FileHandle.standardError.write((s + "\n").data(using: .utf8)!) } let p1 = CGPoint(x: bounds.minX + 0.15 * bounds.width, y: bounds.minY + 0.15 * bounds.height) let p2 = CGPoint(x: bounds.minX + 0.85 * bounds.width, y: bounds.minY + 0.85 * bounds.height) @@ -139,10 +154,10 @@ case "calibrate": cerr("nib: CALIBRATION — place the pen tip exactly on the cursor dot (upper-left area) and hold still…") var calTransport: HIDTransport! calTransport = HIDTransport( - vendorID: VENDOR_ID, productID: PEN_PID, - onAttach: { enableNativeMode(calTransport) }, + vendorID: profile.vendorID, productID: profile.penPID, + onAttach: { enableNativeMode(calTransport, profile) }, onReport: { reportID, payload in - guard reportID == PenReport.reportID, let s = PenReport.parse(payload) else { return } + guard reportID == profile.pen.reportID, let s = PenReport.parse(payload, profile.pen) else { return } if !s.tipDown || !s.inRange { if state == 1 { state = 2; CGWarpMouseCursorPosition(p2) cerr("nib: point 1 captured. Now align the pen to the cursor (lower-right area) and hold…") } @@ -170,68 +185,132 @@ case "run": if !trusted { FileHandle.standardError.write("nib: NOT trusted — events will be dropped. Grant this binary Accessibility in System Settings, then relaunch.\n".data(using: .utf8)!) } - var mapping = DisplayMapping(targetBounds: cintiqDisplayBounds()) - if let cal = Calibration.load() { - cal.apply(to: &mapping) - FileHandle.standardError.write("nib: loaded calibration \(Calibration.fileURL.path)\n".data(using: .utf8)!) - } else { - FileHandle.standardError.write("nib: no calibration found, using full pen range\n".data(using: .utf8)!) - } + func log(_ s: String) { FileHandle.standardError.write("nib: \(s)\n".data(using: .utf8)!) } + let config = NibConfig.load() - let emitter = EventEmitter(mapping: mapping) + // Start with an identity mapping; DeviceManager rebuilds it once a profile resolves. + let emitter = EventEmitter(mapping: DisplayMapping(targetBounds: CGDisplayBounds(CGMainDisplayID()))) let touchGesture = TouchGestureEngine() - // In-app calibration: captures raw pen coords at two on-screen targets, then applies. + + // (Re)build the pen + touch coordinate mappings for a given tablet. Called whenever the + // active device changes, and after an in-app calibration. + var activeProfile = DeviceProfile.dth2420 + func rebuildMappings(for profile: DeviceProfile) { + let bounds = cintiqDisplayBounds(profile) + var penMap = DisplayMapping(targetBounds: bounds, + xMax: Double(profile.pen.xLogicalMax), + yMax: Double(profile.pen.yLogicalMax)) + if let cal = Calibration.load() { + cal.apply(to: &penMap) + log("loaded calibration \(Calibration.fileURL.path)") + } else { + log("no calibration found, using full pen range") + } + emitter.setMapping(penMap) + touchGesture.tapMapping = DisplayMapping( + targetBounds: bounds, + xMin: 0, xMax: Double(profile.touch.xLogicalMax), + yMin: 0, yMax: Double(profile.touch.yLogicalMax)) + } + + // In-app calibration: captures raw pen coords at two on-screen targets, then applies + // them against the currently-active device's display bounds. let calibration = CalibrationController(apply: { cal in - let m = DisplayMapping(targetBounds: cintiqDisplayBounds(), + let m = DisplayMapping(targetBounds: cintiqDisplayBounds(activeProfile), xMin: cal.xMin, xMax: cal.xMax, yMin: cal.yMin, yMax: cal.yMax) emitter.setMapping(m) try? cal.save() }) - // Touch coords (0..15360 x 0..8640) map to the Cintiq display for tap-to-click position. - touchGesture.tapMapping = DisplayMapping( - targetBounds: cintiqDisplayBounds(), - xMin: 0, xMax: Double(TouchContact.xLogicalMax), - yMin: 0, yMax: Double(TouchContact.yLogicalMax)) - var transport: HIDTransport! - transport = HIDTransport( - vendorID: VENDOR_ID, productID: PEN_PID, - onAttach: { - FileHandle.standardError.write("nib: pen attached\n".data(using: .utf8)!) - enableNativeMode(transport) - }, - onReport: { reportID, payload in - guard reportID == PenReport.reportID, let s = PenReport.parse(payload) else { return } + + // DeviceManager owns the pen/touch transports and re-resolves the profile on hotplug, + // so plugging in a supported tablet later (or swapping models) just works. + let manager = DeviceManager(handlers: .init( + onPen: { s in if calibration.isActive { calibration.feed(s); return } // capture instead of emit emitter.handle(s) - touchGesture.setPenInProximity(s.inRange) // palm rejection gating - }) - transport.start() - - // Touch: enable the sensor and drive 2-finger scroll (respecting the engine's enabled flag). - var touch: HIDTransport! - touch = HIDTransport( - vendorID: VENDOR_ID, productID: TOUCH_PID, - onAttach: { - FileHandle.standardError.write("nib: touch attached\n".data(using: .utf8)!) - enableTouch(touch) + touchGesture.setPenInProximity(s.inRange) // palm rejection gating }, - onReport: { reportID, payload in - guard !calibration.isActive else { return } // ignore touch during calibration - guard reportID == TouchReport.reportID, let f = TouchReport.parse(payload) else { return } + onTouch: { f in + guard !calibration.isActive else { return } // ignore touch during calibration touchGesture.handle(f) - }) - touch.start() + }, + onProfileChange: { p in + if let p = p { + activeProfile = p + log("now driving \(p.name)") + rebuildMappings(for: p) + } else { + // Keep `activeProfile` at its last value: with no tablet, display-bounds and + // recalibration have no meaningful target anyway, and the last-known profile + // is the sanest fallback (and the likely one on replug). + log("no supported tablet connected") + } + }, + onEnableResult: { iface, results in + let ok = results.allSatisfy { $0 } + log("\(iface)-enable \(ok ? "ok" : "FAILED \(results)")") + })) + manager.start() + + // Re-map when displays change — notably when the tablet's panel appears a beat after + // its HID interface on replug (otherwise the pen lands on the fallback display). + onDisplayReconfigured = { rebuildMappings(for: activeProfile) } + CGDisplayRegisterReconfigurationCallback(displayReconfigCallback, nil) FileHandle.standardError.write("nib: running (needs Accessibility/Input-Monitoring permission)\n".data(using: .utf8)!) // Menu-bar agent: status item applies config live to the engines. NSApplication.run() // drives the main run loop, which the HID transports are scheduled on. let app = NSApplication.shared app.setActivationPolicy(.accessory) - let statusBar = StatusBarController(config: config, emitter: emitter, touch: touchGesture, calibration: calibration) + let statusBar = StatusBarController(config: config, emitter: emitter, touch: touchGesture, + calibration: calibration, + calibrationBounds: { cintiqDisplayBounds(activeProfile) }) _ = statusBar // retained for the process lifetime app.run() +case "capture": + // Raw multi-interface HID dump for bringing up a NEW device. Opens every connected + // interface of each known vendor, sends the best-guess Wacom enable sequences, and + // dumps every report as raw hex tagged by PID. Run this, then perform the labeled + // gestures from docs/ADDING-A-DEVICE.md (corners, hard press, lift, 1/2/3-finger + // touches) to produce a capture bundle for a new DeviceProfile + fixtures. + FileHandle.standardError.write("nib: CAPTURE mode — raw report dump for a new device (Ctrl-C to stop)\n".data(using: .utf8)!) + let guess = DeviceProfile.dth2420 // best-guess enable sequences for the Wacom family + var ifaces: [(vendor: UInt16, pid: UInt16)] = [] + for v in Set(DeviceProfile.all.map({ $0.vendorID })) { + for info in HIDTransport.enumerate(vendorID: v) + where !ifaces.contains(where: { $0.vendor == info.vendorID && $0.pid == info.productID }) { + ifaces.append((info.vendorID, info.productID)) + } + } + if ifaces.isEmpty { + FileHandle.standardError.write("nib: no HID interfaces found for known vendors\n".data(using: .utf8)!) + exit(1) + } + let ifaceList = ifaces.map { String(format: "0x%04x", $0.pid) }.joined(separator: " ") + FileHandle.standardError.write("nib: capturing \(ifaces.count) interface(s): \(ifaceList)\n".data(using: .utf8)!) + var captureTransports: [HIDTransport] = [] + for iface in ifaces { + var t: HIDTransport! + t = HIDTransport( + vendorID: iface.vendor, productID: iface.pid, + onAttach: { + FileHandle.standardError.write(String(format: "nib: opened 0x%04x; sending best-guess enable\n", iface.pid).data(using: .utf8)!) + _ = t.applyFeatures(guess.initSequence) // best effort; failures are expected on non-pen interfaces + _ = t.applyOutputs(guess.touchEnable) // best effort; failures are expected on non-touch interfaces + }, + onReport: { reportID, payload in + let hex = payload.map { String(format: "%02x", $0) }.joined(separator: " ") + FileHandle.standardOutput.write(String(format: "pid=0x%04x id=0x%02x len=%3d | %@\n", + iface.pid, reportID, payload.count, hex).data(using: .utf8)!) + }) + t.start() + captureTransports.append(t) + } + _ = captureTransports // retained for the process lifetime + CFRunLoopRun() + default: - FileHandle.standardError.write("usage: nib [run|log|calibrate]\n".data(using: .utf8)!) + FileHandle.standardError.write("usage: nib [run|log|touchlog|calibrate|capture]\n".data(using: .utf8)!) exit(2) } diff --git a/driver/Tests/NibCoreTests/DeviceFixtures.swift b/driver/Tests/NibCoreTests/DeviceFixtures.swift new file mode 100644 index 0000000..d74839a --- /dev/null +++ b/driver/Tests/NibCoreTests/DeviceFixtures.swift @@ -0,0 +1,72 @@ +import NibCore + +// Device fixture registry — the heart of the "add a tablet" contribution pipeline. +// +// Each supported device contributes one `DeviceFixtureBundle`: its `DeviceProfile` plus a +// handful of REAL, hand-labeled captured reports (grab them with `nib capture` — see +// docs/ADDING-A-DEVICE.md). `ProfileConformanceTests` then asserts the profile decodes +// every fixture correctly, so a new profile is validated against actual hardware output +// without the maintainer ever needing the device. +// +// To add a device: append a `DeviceFixtureBundle` to `DeviceFixtures.all`. Fill in only +// the expected fields you can label with confidence (leave the rest nil). + +/// One captured pen report and the decoded values it should produce. Optional fields are +/// asserted only when non-nil, so you assert exactly what you labeled. +struct PenFixture { + let name: String + let hex: String // space-separated hex bytes, report-ID byte first + var tipDown: Bool? + var inRange: Bool? + var x: Int? + var y: Int? + var pressure: Int? + var serial: UInt32? +} + +/// One captured touch report and the decoded values it should produce. +struct TouchFixture { + let name: String + let hex: String + var contactCount: Int? + var firstX: Int? + var firstY: Int? + var contactIDs: [Int]? +} + +/// A device's profile plus the captured gestures that validate it. +struct DeviceFixtureBundle { + let profile: DeviceProfile + let pen: [PenFixture] + let touch: [TouchFixture] +} + +enum DeviceFixtures { + /// Every device the test suite validates. Append new bundles here. + static let all: [DeviceFixtureBundle] = [dth2420] + + /// Wacom Cintiq Pro 24 Touch (DTH2420) — the reference device. Captures are the same + /// real reports used elsewhere in the suite (corners, press, hover, lift, N-finger). + static let dth2420 = DeviceFixtureBundle( + profile: .dth2420, + pen: [ + PenFixture(name: "center press", + hex: "10 61 fa c3 00 2d 69 00 51 0b f4 01 00 00 00 00 20 11 22 33 44 42 08 10 00 42 08", + tipDown: true, inRange: true, x: 0x00c3fa, y: 0x00692d, pressure: 0x0b51, + serial: 0x44332211), + PenFixture(name: "bottom-right hover", + hex: "10 60 26 98 01 87 e5 00 00 00 ff f1 00 00 00 00 2d 11 22 33 44 42 08 10 00 42 08", + tipDown: false, inRange: true, x: 0x019826, y: 0x00e587, pressure: 0), + PenFixture(name: "lifted out of range", + hex: "10 00 32 c0 00 b7 66 00 00 00 00 00 00 00 00 00 3f 11 22 33 44 42 08 10 00 42 08", + tipDown: false, inRange: false), + ], + touch: [ + TouchFixture(name: "one finger", + hex: "81 01 00 ec 25 aa 09 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 33 00 01", + contactCount: 1, firstX: 0x25ec, firstY: 0x09aa, contactIDs: [0]), + TouchFixture(name: "three fingers", + hex: "81 01 00 6d 1c 7e 06 01 01 be 17 bc 05 01 02 fa 19 98 10 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 6e 17 03", + contactCount: 3, contactIDs: [0, 1, 2]), + ]) +} diff --git a/driver/Tests/NibCoreTests/PenReportTests.swift b/driver/Tests/NibCoreTests/PenReportTests.swift index 82c047a..9088847 100644 --- a/driver/Tests/NibCoreTests/PenReportTests.swift +++ b/driver/Tests/NibCoreTests/PenReportTests.swift @@ -47,6 +47,15 @@ final class PenReportTests: XCTestCase { XCTAssertNil(PenReport.parse(wrongID)) // wrong report ID } + func testParsesViaExplicitProfileLayout() throws { + // The layout-parameterized path (used for multi-device support) must decode the + // reference report identically to the default. + let r = hex("10 61 fa c3 00 2d 69 00 51 0b f4 01 00 00 00 00 20 11 22 33 44 42 08 10 00 42 08") + let viaDefault = try XCTUnwrap(PenReport.parse(r)) + let viaProfile = try XCTUnwrap(PenReport.parse(r, DeviceProfile.dth2420.pen)) + XCTAssertEqual(viaDefault, viaProfile) + } + func testMappingCornersAndClamp() { let m = DisplayMapping(targetBounds: CGRect(x: 0, y: 0, width: 3840, height: 2160)) XCTAssertEqual(m.point(x: 0, y: 0), CGPoint(x: 0, y: 0)) diff --git a/driver/Tests/NibCoreTests/ProfileConformanceTests.swift b/driver/Tests/NibCoreTests/ProfileConformanceTests.swift new file mode 100644 index 0000000..7d4f81f --- /dev/null +++ b/driver/Tests/NibCoreTests/ProfileConformanceTests.swift @@ -0,0 +1,55 @@ +import XCTest +@testable import NibCore + +/// Data-driven validation: every registered device profile must decode its real captured +/// gestures (see DeviceFixtures.swift). Adding a device with fixtures automatically adds +/// its coverage here — no per-device test code. This is what lets a maintainer accept a +/// new profile with confidence without owning the hardware. +final class ProfileConformanceTests: XCTestCase { + private func hex(_ s: String) -> [UInt8] { + s.split(separator: " ").map { UInt8($0, radix: 16)! } + } + + func testEveryProfileIsRegistered() { + // Each fixture bundle's profile should also be in DeviceProfile.all, so detection + // and decoding stay in sync. + for bundle in DeviceFixtures.all { + XCTAssertTrue(DeviceProfile.all.contains { $0.name == bundle.profile.name }, + "\(bundle.profile.name) has fixtures but is not in DeviceProfile.all") + } + } + + func testPenFixturesDecode() { + for bundle in DeviceFixtures.all { + let p = bundle.profile + for f in bundle.pen { + let label = "\(p.name) / pen '\(f.name)'" + guard let s = PenReport.parse(hex(f.hex), p.pen) else { + XCTFail("\(label): failed to parse"); continue + } + if let v = f.tipDown { XCTAssertEqual(s.tipDown, v, "\(label): tipDown") } + if let v = f.inRange { XCTAssertEqual(s.inRange, v, "\(label): inRange") } + if let v = f.x { XCTAssertEqual(s.x, v, "\(label): x") } + if let v = f.y { XCTAssertEqual(s.y, v, "\(label): y") } + if let v = f.pressure { XCTAssertEqual(s.pressure, v, "\(label): pressure") } + if let v = f.serial { XCTAssertEqual(s.serial, v, "\(label): serial") } + } + } + } + + func testTouchFixturesDecode() { + for bundle in DeviceFixtures.all { + let p = bundle.profile + for f in bundle.touch { + let label = "\(p.name) / touch '\(f.name)'" + guard let frame = TouchReport.parse(hex(f.hex), p.touch) else { + XCTFail("\(label): failed to parse"); continue + } + if let v = f.contactCount { XCTAssertEqual(frame.contactCount, v, "\(label): contactCount") } + if let v = f.contactIDs { XCTAssertEqual(frame.contacts.map { $0.id }, v, "\(label): contact IDs") } + if let v = f.firstX { XCTAssertEqual(frame.contacts.first?.x, v, "\(label): first contact x") } + if let v = f.firstY { XCTAssertEqual(frame.contacts.first?.y, v, "\(label): first contact y") } + } + } + } +} diff --git a/driver/Tests/NibCoreTests/TouchReportTests.swift b/driver/Tests/NibCoreTests/TouchReportTests.swift index 6ea37bf..d9ededa 100644 --- a/driver/Tests/NibCoreTests/TouchReportTests.swift +++ b/driver/Tests/NibCoreTests/TouchReportTests.swift @@ -36,6 +36,13 @@ final class TouchReportTests: XCTestCase { XCTAssertEqual(f.contacts.map { $0.id }, [0, 1, 2]) } + func testParsesViaExplicitProfileLayout() throws { + let r = hex("81 01 00 ec 25 aa 09 ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff 33 00 01") + let viaDefault = try XCTUnwrap(TouchReport.parse(r)) + let viaProfile = try XCTUnwrap(TouchReport.parse(r, DeviceProfile.dth2420.touch)) + XCTAssertEqual(viaDefault, viaProfile) + } + func testRejectsWrongID() { XCTAssertNil(TouchReport.parse([UInt8](repeating: 0x10, count: 64))) }