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:
+## 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)))
}