macslap 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/binding.gyp +32 -0
- package/examples/basic.js +37 -0
- package/examples/deploy-slap.js +44 -0
- package/examples/music-control.js +82 -0
- package/examples/tamper-detect.js +49 -0
- package/examples/webhook.js +44 -0
- package/lib/index.d.ts +99 -0
- package/lib/index.js +287 -0
- package/package.json +49 -0
- package/src/accelerometer.mm +365 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# macslap
|
|
2
|
+
|
|
3
|
+
> Run any function when your MacBook gets slapped. 🖐️💻
|
|
4
|
+
|
|
5
|
+
A Node.js package that reads the Apple Silicon accelerometer (Bosch BMI286 IMU) via IOKit HID to detect physical taps, slaps, and hits on your MacBook. Register any callback — deploy code, trigger webhooks, control music, or whatever you want.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Apple Silicon MacBook (M1 Pro, M2, M3, M4+)
|
|
10
|
+
- macOS 14+ (Sonoma)
|
|
11
|
+
- Node.js 16+
|
|
12
|
+
- **Must run with `sudo`** (IOKit HID requires root)
|
|
13
|
+
|
|
14
|
+
Check if your Mac supports it:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
ioreg -l -w0 | grep -A5 AppleSPUHIDDevice
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install macslap
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
const { onSlap } = require('macslap');
|
|
30
|
+
|
|
31
|
+
onSlap((event) => {
|
|
32
|
+
console.log(`Slapped! Force: ${event.force.toFixed(3)}g`);
|
|
33
|
+
console.log(`Type: ${event.type}`); // "tap", "slap", "hit", or "punch"
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
sudo node my-script.js
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## API
|
|
42
|
+
|
|
43
|
+
### Simple API
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
const { onSlap, onTap, onHit, onPunch, onForce, isSupported } = require('macslap');
|
|
47
|
+
|
|
48
|
+
// Any impact
|
|
49
|
+
onSlap((event) => { /* ... */ });
|
|
50
|
+
|
|
51
|
+
// Gentle taps only (< 0.15g)
|
|
52
|
+
onTap((event) => { /* ... */ });
|
|
53
|
+
|
|
54
|
+
// Hard hits (0.5 - 1.0g)
|
|
55
|
+
onHit((event) => { /* ... */ });
|
|
56
|
+
|
|
57
|
+
// Very hard impacts (> 1.0g)
|
|
58
|
+
onPunch((event) => { /* ... */ });
|
|
59
|
+
|
|
60
|
+
// Custom force range
|
|
61
|
+
onForce(0.1, 0.3, (event) => { /* ... */ });
|
|
62
|
+
|
|
63
|
+
// Check hardware support
|
|
64
|
+
if (isSupported()) { /* good to go */ }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
All functions return an **unsubscribe** function:
|
|
68
|
+
|
|
69
|
+
```js
|
|
70
|
+
const unsub = onSlap((event) => { /* ... */ });
|
|
71
|
+
// Later:
|
|
72
|
+
unsub(); // stop listening
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Options
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
onSlap(callback, {
|
|
79
|
+
cooldown: 750, // ms between events (default: 750)
|
|
80
|
+
threshold: 0.05, // min force in g to trigger (default: 0.05)
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Event Object
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
{
|
|
88
|
+
x: -0.012, // X-axis acceleration in g
|
|
89
|
+
y: 0.003, // Y-axis acceleration in g
|
|
90
|
+
z: -1.024, // Z-axis acceleration in g
|
|
91
|
+
force: 0.234, // magnitude of impact in g
|
|
92
|
+
type: "slap", // "tap" | "slap" | "hit" | "punch"
|
|
93
|
+
timestamp: 1711500000000
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Force Classification
|
|
98
|
+
|
|
99
|
+
| Type | Force Range | Description |
|
|
100
|
+
|------|-------------|-------------|
|
|
101
|
+
| `tap` | < 0.15g | Gentle touch, light tap |
|
|
102
|
+
| `slap` | 0.15 - 0.5g | Normal slap |
|
|
103
|
+
| `hit` | 0.5 - 1.0g | Hard hit |
|
|
104
|
+
| `punch` | > 1.0g | Full send |
|
|
105
|
+
|
|
106
|
+
### Advanced API: SlapDetector
|
|
107
|
+
|
|
108
|
+
For more control, use the `SlapDetector` class directly:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
const { SlapDetector } = require('macslap');
|
|
112
|
+
|
|
113
|
+
const detector = new SlapDetector({
|
|
114
|
+
threshold: 0.02, // ultra sensitive
|
|
115
|
+
cooldown: 300,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Event emitter pattern
|
|
119
|
+
detector.on('slap', (event) => { /* any impact */ });
|
|
120
|
+
detector.on('tap', (event) => { /* gentle */ });
|
|
121
|
+
detector.on('hit', (event) => { /* hard */ });
|
|
122
|
+
detector.on('ready', () => console.log('Listening...'));
|
|
123
|
+
|
|
124
|
+
// Conditional handlers
|
|
125
|
+
detector.when('punch', (event) => {
|
|
126
|
+
console.log('That was violent');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Custom conditions
|
|
130
|
+
detector.when(
|
|
131
|
+
(event) => event.force > 0.3 && event.x > 0,
|
|
132
|
+
(event) => console.log('Slapped from the right!')
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// Read raw values
|
|
136
|
+
const reading = detector.read();
|
|
137
|
+
// { x, y, z, running, calibrated }
|
|
138
|
+
|
|
139
|
+
detector.start();
|
|
140
|
+
|
|
141
|
+
// Later:
|
|
142
|
+
detector.stop();
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Examples
|
|
146
|
+
|
|
147
|
+
### Deploy on Slap
|
|
148
|
+
|
|
149
|
+
```js
|
|
150
|
+
const { onSlap } = require('macslap');
|
|
151
|
+
const { exec } = require('child_process');
|
|
152
|
+
|
|
153
|
+
onSlap((event) => {
|
|
154
|
+
console.log('🚀 SHIPPING IT');
|
|
155
|
+
exec('git push origin main');
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Webhook Trigger
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
const { onSlap } = require('macslap');
|
|
163
|
+
|
|
164
|
+
onSlap(async (event) => {
|
|
165
|
+
await fetch('https://your-webhook.com', {
|
|
166
|
+
method: 'POST',
|
|
167
|
+
headers: { 'Content-Type': 'application/json' },
|
|
168
|
+
body: JSON.stringify({
|
|
169
|
+
event: 'slapped',
|
|
170
|
+
force: event.force,
|
|
171
|
+
timestamp: new Date().toISOString(),
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Tamper Detection
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
const { SlapDetector } = require('macslap');
|
|
181
|
+
|
|
182
|
+
const detector = new SlapDetector({
|
|
183
|
+
threshold: 0.02, // ultra sensitive
|
|
184
|
+
cooldown: 2000,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
detector.on('slap', (event) => {
|
|
188
|
+
console.log('🚨 SOMEONE TOUCHED YOUR LAPTOP');
|
|
189
|
+
// Take photo, send notification, sound alarm, etc.
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
detector.start();
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Music Control with Tap Patterns
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
const { SlapDetector } = require('macslap');
|
|
199
|
+
const { exec } = require('child_process');
|
|
200
|
+
|
|
201
|
+
const detector = new SlapDetector({ threshold: 0.03, cooldown: 300 });
|
|
202
|
+
let taps = [], timer;
|
|
203
|
+
|
|
204
|
+
detector.on('slap', () => {
|
|
205
|
+
taps.push(Date.now());
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
timer = setTimeout(() => {
|
|
208
|
+
const count = taps.length;
|
|
209
|
+
taps = [];
|
|
210
|
+
if (count === 1) exec(`osascript -e 'tell app "Spotify" to playpause'`);
|
|
211
|
+
if (count === 2) exec(`osascript -e 'tell app "Spotify" to next track'`);
|
|
212
|
+
if (count === 3) exec(`osascript -e 'tell app "Spotify" to previous track'`);
|
|
213
|
+
}, 500);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
detector.start();
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## How It Works
|
|
220
|
+
|
|
221
|
+
The package uses a native C++ addon (compiled via `node-gyp`) that:
|
|
222
|
+
|
|
223
|
+
1. Opens the `AppleSPUHIDDevice` in the IOKit registry (vendor usage page `0xFF00`, usage `3`)
|
|
224
|
+
2. Registers an async HID input report callback at ~100Hz
|
|
225
|
+
3. Parses 22-byte reports: X/Y/Z as int32 LE at byte offsets 6, 10, 14 (divided by 65536 for g values)
|
|
226
|
+
4. Computes a rolling baseline and detects deviations (slaps)
|
|
227
|
+
5. Fires events back to JavaScript via N-API thread-safe functions
|
|
228
|
+
|
|
229
|
+
## Troubleshooting
|
|
230
|
+
|
|
231
|
+
**"Accelerometer not found"**
|
|
232
|
+
- Run with `sudo`: `sudo node script.js`
|
|
233
|
+
- Check hardware: `ioreg -l -w0 | grep AppleSPUHIDDevice`
|
|
234
|
+
- M1 (base) may not work — M1 Pro, M2+ confirmed
|
|
235
|
+
|
|
236
|
+
**"Failed to load native module"**
|
|
237
|
+
- Make sure Xcode Command Line Tools are installed: `xcode-select --install`
|
|
238
|
+
- Rebuild: `npm rebuild`
|
|
239
|
+
- Check Node.js version: needs 16+
|
|
240
|
+
|
|
241
|
+
**Triggers during typing**
|
|
242
|
+
- Increase threshold: `{ threshold: 0.1 }` or higher
|
|
243
|
+
- Increase cooldown: `{ cooldown: 1500 }`
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
MIT
|
package/binding.gyp
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"targets": [
|
|
3
|
+
{
|
|
4
|
+
"target_name": "macslap_native",
|
|
5
|
+
"sources": ["src/accelerometer.mm"],
|
|
6
|
+
"include_dirs": [
|
|
7
|
+
"<!@(node -p \"require('node-addon-api').include\")"
|
|
8
|
+
],
|
|
9
|
+
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"],
|
|
10
|
+
"conditions": [
|
|
11
|
+
[
|
|
12
|
+
"OS=='mac'",
|
|
13
|
+
{
|
|
14
|
+
"xcode_settings": {
|
|
15
|
+
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
|
|
16
|
+
"CLANG_CXX_LIBRARY": "libc++",
|
|
17
|
+
"MACOSX_DEPLOYMENT_TARGET": "14.0",
|
|
18
|
+
"OTHER_CFLAGS": ["-ObjC++"]
|
|
19
|
+
},
|
|
20
|
+
"link_settings": {
|
|
21
|
+
"libraries": [
|
|
22
|
+
"-framework IOKit",
|
|
23
|
+
"-framework CoreFoundation",
|
|
24
|
+
"-framework Foundation"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic macslap example
|
|
3
|
+
*
|
|
4
|
+
* Run with: sudo node examples/basic.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { onSlap, onTap, onHit, onPunch, isSupported } = require("../lib");
|
|
8
|
+
|
|
9
|
+
// Check hardware first
|
|
10
|
+
if (!isSupported()) {
|
|
11
|
+
console.error("❌ Apple Silicon accelerometer not found.");
|
|
12
|
+
console.error(" Requirements: M1 Pro or M2+ MacBook, macOS 14+, sudo");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
console.log("🖐️ macslap is listening... Give your MacBook a slap!\n");
|
|
17
|
+
console.log(" Tap = gentle touch (< 0.15g)");
|
|
18
|
+
console.log(" Slap = normal slap (0.15 - 0.5g)");
|
|
19
|
+
console.log(" Hit = hard hit (0.5 - 1.0g)");
|
|
20
|
+
console.log(" Punch = full send (> 1.0g)");
|
|
21
|
+
console.log("\n Press Ctrl+C to stop.\n");
|
|
22
|
+
|
|
23
|
+
// Listen for all events
|
|
24
|
+
onSlap((event) => {
|
|
25
|
+
const emoji = {
|
|
26
|
+
tap: "👆",
|
|
27
|
+
slap: "🖐️",
|
|
28
|
+
hit: "👊",
|
|
29
|
+
punch: "💥",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
console.log(
|
|
33
|
+
`${emoji[event.type] || "❓"} ${event.type.toUpperCase()} ` +
|
|
34
|
+
`| force: ${event.force.toFixed(3)}g ` +
|
|
35
|
+
`| x: ${event.x.toFixed(3)} y: ${event.y.toFixed(3)} z: ${event.z.toFixed(3)}`
|
|
36
|
+
);
|
|
37
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy on Slap - Push code by slapping your MacBook
|
|
3
|
+
*
|
|
4
|
+
* Run with: sudo node examples/deploy-slap.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { onSlap, onHit, isSupported } = require("../lib");
|
|
8
|
+
const { exec } = require("child_process");
|
|
9
|
+
|
|
10
|
+
if (!isSupported()) {
|
|
11
|
+
console.error("❌ Accelerometer not found. Need M1 Pro+ MacBook with sudo.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
console.log("🚀 Deploy-on-Slap is armed!\n");
|
|
16
|
+
console.log(" Slap → git push origin main");
|
|
17
|
+
console.log(" Hit → git push origin main --force");
|
|
18
|
+
console.log("\n Press Ctrl+C to disarm.\n");
|
|
19
|
+
|
|
20
|
+
let deploying = false;
|
|
21
|
+
|
|
22
|
+
onSlap((event) => {
|
|
23
|
+
if (deploying) {
|
|
24
|
+
console.log("⏳ Already deploying, chill...");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const force = event.type === "hit" || event.type === "punch";
|
|
29
|
+
const cmd = force
|
|
30
|
+
? "git push origin main --force"
|
|
31
|
+
: "git push origin main";
|
|
32
|
+
|
|
33
|
+
console.log(`\n🖐️ SLAP DETECTED (${event.force.toFixed(3)}g) → ${cmd}`);
|
|
34
|
+
deploying = true;
|
|
35
|
+
|
|
36
|
+
exec(cmd, (error, stdout, stderr) => {
|
|
37
|
+
deploying = false;
|
|
38
|
+
if (error) {
|
|
39
|
+
console.log(`❌ Deploy failed: ${stderr || error.message}`);
|
|
40
|
+
} else {
|
|
41
|
+
console.log(`✅ Deployed! ${stdout}`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Music Controller - Control Spotify/Apple Music with slap gestures
|
|
3
|
+
*
|
|
4
|
+
* Run with: sudo node examples/music-control.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { SlapDetector, isSupported } = require("../lib");
|
|
8
|
+
const { exec } = require("child_process");
|
|
9
|
+
|
|
10
|
+
if (!isSupported()) {
|
|
11
|
+
console.error("❌ Accelerometer not found.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// AppleScript commands for media control
|
|
16
|
+
const media = {
|
|
17
|
+
playPause: `osascript -e 'tell application "System Events" to key code 49 using {}'`,
|
|
18
|
+
// Use media keys
|
|
19
|
+
toggle: `osascript -e 'tell application "Spotify" to playpause'`,
|
|
20
|
+
next: `osascript -e 'tell application "Spotify" to next track'`,
|
|
21
|
+
prev: `osascript -e 'tell application "Spotify" to previous track'`,
|
|
22
|
+
volUp: `osascript -e 'set volume output volume ((output volume of (get volume settings)) + 10)'`,
|
|
23
|
+
volDown: `osascript -e 'set volume output volume ((output volume of (get volume settings)) - 10)'`,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const detector = new SlapDetector({
|
|
27
|
+
threshold: 0.03,
|
|
28
|
+
cooldown: 300, // Short cooldown to detect patterns
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let tapTimes = [];
|
|
32
|
+
let patternTimer = null;
|
|
33
|
+
|
|
34
|
+
function executePattern(count) {
|
|
35
|
+
switch (count) {
|
|
36
|
+
case 1:
|
|
37
|
+
console.log("⏯️ Single tap → Play/Pause");
|
|
38
|
+
exec(media.toggle);
|
|
39
|
+
break;
|
|
40
|
+
case 2:
|
|
41
|
+
console.log("⏭️ Double tap → Next Track");
|
|
42
|
+
exec(media.next);
|
|
43
|
+
break;
|
|
44
|
+
case 3:
|
|
45
|
+
console.log("⏮️ Triple tap → Previous Track");
|
|
46
|
+
exec(media.prev);
|
|
47
|
+
break;
|
|
48
|
+
default:
|
|
49
|
+
console.log(`🔢 ${count} taps — no action mapped`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
detector.on("slap", (event) => {
|
|
54
|
+
tapTimes.push(Date.now());
|
|
55
|
+
|
|
56
|
+
// Clear previous timer
|
|
57
|
+
if (patternTimer) clearTimeout(patternTimer);
|
|
58
|
+
|
|
59
|
+
// Wait 500ms after last tap to evaluate the pattern
|
|
60
|
+
patternTimer = setTimeout(() => {
|
|
61
|
+
const count = tapTimes.length;
|
|
62
|
+
tapTimes = [];
|
|
63
|
+
executePattern(count);
|
|
64
|
+
}, 500);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Hard hits for volume
|
|
68
|
+
detector.when("hit", (event) => {
|
|
69
|
+
console.log("🔊 Hard hit → Volume Up");
|
|
70
|
+
exec(media.volUp);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
detector.on("ready", () => {
|
|
74
|
+
console.log("🎵 Music Controller is active!\n");
|
|
75
|
+
console.log(" 1 tap → Play/Pause");
|
|
76
|
+
console.log(" 2 taps → Next Track");
|
|
77
|
+
console.log(" 3 taps → Previous Track");
|
|
78
|
+
console.log(" Hard hit → Volume Up");
|
|
79
|
+
console.log("\n (Uses Spotify — edit the script for Apple Music)\n");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
detector.start();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tamper Detection - Alert when someone touches your MacBook
|
|
3
|
+
*
|
|
4
|
+
* Run with: sudo node examples/tamper-detect.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { SlapDetector, isSupported } = require("../lib");
|
|
8
|
+
|
|
9
|
+
if (!isSupported()) {
|
|
10
|
+
console.error("❌ Accelerometer not found.");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const detector = new SlapDetector({
|
|
15
|
+
threshold: 0.02, // Very sensitive - detect even light touches
|
|
16
|
+
cooldown: 2000, // 2s between alerts
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let slapCount = 0;
|
|
20
|
+
const startTime = Date.now();
|
|
21
|
+
|
|
22
|
+
detector.on("slap", (event) => {
|
|
23
|
+
slapCount++;
|
|
24
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
|
|
25
|
+
|
|
26
|
+
console.log(
|
|
27
|
+
`\n🚨 TAMPER DETECTED #${slapCount} ` +
|
|
28
|
+
`[${elapsed}s since armed] ` +
|
|
29
|
+
`force: ${event.force.toFixed(4)}g`
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// You could add here:
|
|
33
|
+
// - Take a webcam photo with `imagesnap`
|
|
34
|
+
// - Send a push notification
|
|
35
|
+
// - Play an alarm sound
|
|
36
|
+
// - Send a webhook to your phone
|
|
37
|
+
// - Log to a file
|
|
38
|
+
|
|
39
|
+
console.log(" → Add your alert logic here (webhook, photo, alarm, etc.)");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
detector.on("ready", () => {
|
|
43
|
+
console.log("🔒 Tamper detection ARMED\n");
|
|
44
|
+
console.log(" Sensitivity: ultra (0.02g threshold)");
|
|
45
|
+
console.log(" Any touch, bump, or movement will trigger an alert.");
|
|
46
|
+
console.log("\n Press Ctrl+C to disarm.\n");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
detector.start();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook on Slap - Send HTTP requests when your MacBook gets slapped
|
|
3
|
+
*
|
|
4
|
+
* Run with: sudo node examples/webhook.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { onSlap, isSupported } = require("../lib");
|
|
8
|
+
|
|
9
|
+
if (!isSupported()) {
|
|
10
|
+
console.error("❌ Accelerometer not found.");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Configure your webhook URL
|
|
15
|
+
const WEBHOOK_URL =
|
|
16
|
+
process.env.WEBHOOK_URL || "https://httpbin.org/post";
|
|
17
|
+
|
|
18
|
+
console.log("🌐 Webhook-on-Slap is active!\n");
|
|
19
|
+
console.log(` Target: ${WEBHOOK_URL}`);
|
|
20
|
+
console.log(" Every slap sends a POST request.\n");
|
|
21
|
+
|
|
22
|
+
onSlap(async (event) => {
|
|
23
|
+
const payload = {
|
|
24
|
+
event: "macbook_slapped",
|
|
25
|
+
force: event.force,
|
|
26
|
+
type: event.type,
|
|
27
|
+
acceleration: { x: event.x, y: event.y, z: event.z },
|
|
28
|
+
timestamp: new Date().toISOString(),
|
|
29
|
+
machine: require("os").hostname(),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
console.log(`🖐️ ${event.type.toUpperCase()} (${event.force.toFixed(3)}g) → POST ${WEBHOOK_URL}`);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(WEBHOOK_URL, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify(payload),
|
|
39
|
+
});
|
|
40
|
+
console.log(` ✅ ${res.status} ${res.statusText}`);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.log(` ❌ Failed: ${err.message}`);
|
|
43
|
+
}
|
|
44
|
+
});
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
declare module "macslap" {
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
|
|
4
|
+
/** Accelerometer event data */
|
|
5
|
+
interface SlapEvent {
|
|
6
|
+
/** X-axis acceleration in g */
|
|
7
|
+
x: number;
|
|
8
|
+
/** Y-axis acceleration in g */
|
|
9
|
+
y: number;
|
|
10
|
+
/** Z-axis acceleration in g */
|
|
11
|
+
z: number;
|
|
12
|
+
/** Magnitude of force (deviation from baseline) in g */
|
|
13
|
+
force: number;
|
|
14
|
+
/** Event classification */
|
|
15
|
+
type: "tap" | "slap" | "hit" | "punch";
|
|
16
|
+
/** Unix timestamp in ms */
|
|
17
|
+
timestamp: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Raw accelerometer reading */
|
|
21
|
+
interface AccelReading {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
z: number;
|
|
25
|
+
running: boolean;
|
|
26
|
+
calibrated: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Options for slap detection */
|
|
30
|
+
interface SlapOptions {
|
|
31
|
+
/** Cooldown between events in ms (default: 750) */
|
|
32
|
+
cooldown?: number;
|
|
33
|
+
/** Minimum force threshold in g (default: 0.05) */
|
|
34
|
+
threshold?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Callback for slap events */
|
|
38
|
+
type SlapCallback = (event: SlapEvent) => void;
|
|
39
|
+
|
|
40
|
+
/** Unsubscribe function */
|
|
41
|
+
type Unsubscribe = () => void;
|
|
42
|
+
|
|
43
|
+
// ---- Simple API ----
|
|
44
|
+
|
|
45
|
+
/** Listen for any slap/tap/hit event */
|
|
46
|
+
export function onSlap(callback: SlapCallback, options?: SlapOptions): Unsubscribe;
|
|
47
|
+
|
|
48
|
+
/** Listen for gentle taps (force < 0.15g) */
|
|
49
|
+
export function onTap(callback: SlapCallback, options?: SlapOptions): Unsubscribe;
|
|
50
|
+
|
|
51
|
+
/** Listen for hard hits (force 0.5-1.0g) */
|
|
52
|
+
export function onHit(callback: SlapCallback, options?: SlapOptions): Unsubscribe;
|
|
53
|
+
|
|
54
|
+
/** Listen for very hard impacts (force > 1.0g) */
|
|
55
|
+
export function onPunch(callback: SlapCallback, options?: SlapOptions): Unsubscribe;
|
|
56
|
+
|
|
57
|
+
/** Listen for a custom force range */
|
|
58
|
+
export function onForce(
|
|
59
|
+
min: number,
|
|
60
|
+
max: number,
|
|
61
|
+
callback: SlapCallback,
|
|
62
|
+
options?: SlapOptions
|
|
63
|
+
): Unsubscribe;
|
|
64
|
+
|
|
65
|
+
/** Check if the Apple Silicon accelerometer is available */
|
|
66
|
+
export function isSupported(): boolean;
|
|
67
|
+
|
|
68
|
+
// ---- Advanced API ----
|
|
69
|
+
|
|
70
|
+
export class SlapDetector extends EventEmitter {
|
|
71
|
+
constructor(options?: SlapOptions);
|
|
72
|
+
|
|
73
|
+
/** Start listening for slaps */
|
|
74
|
+
start(): this;
|
|
75
|
+
|
|
76
|
+
/** Stop listening */
|
|
77
|
+
stop(): this;
|
|
78
|
+
|
|
79
|
+
/** Read current raw accelerometer values */
|
|
80
|
+
read(): AccelReading;
|
|
81
|
+
|
|
82
|
+
/** Register handler for a named intensity or custom condition */
|
|
83
|
+
when(
|
|
84
|
+
condition: "tap" | "slap" | "hit" | "punch" | ((event: SlapEvent) => boolean),
|
|
85
|
+
callback: SlapCallback
|
|
86
|
+
): Unsubscribe;
|
|
87
|
+
|
|
88
|
+
/** Check hardware support */
|
|
89
|
+
static isSupported(): boolean;
|
|
90
|
+
|
|
91
|
+
on(event: "slap", listener: SlapCallback): this;
|
|
92
|
+
on(event: "tap", listener: SlapCallback): this;
|
|
93
|
+
on(event: "hit", listener: SlapCallback): this;
|
|
94
|
+
on(event: "punch", listener: SlapCallback): this;
|
|
95
|
+
on(event: "ready", listener: () => void): this;
|
|
96
|
+
on(event: "stop", listener: () => void): this;
|
|
97
|
+
on(event: "error", listener: (err: Error) => void): this;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macslap - Detect physical slaps on Apple Silicon MacBooks
|
|
3
|
+
*
|
|
4
|
+
* Run any function when your MacBook gets slapped.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const { onSlap, onTap, onHit } = require('macslap');
|
|
8
|
+
*
|
|
9
|
+
* onSlap((event) => {
|
|
10
|
+
* console.log(`Slapped with force: ${event.force}`);
|
|
11
|
+
* });
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const EventEmitter = require("events");
|
|
15
|
+
const path = require("path");
|
|
16
|
+
|
|
17
|
+
let native;
|
|
18
|
+
try {
|
|
19
|
+
native = require("../build/Release/macslap_native.node");
|
|
20
|
+
} catch (e) {
|
|
21
|
+
try {
|
|
22
|
+
native = require("../build/Debug/macslap_native.node");
|
|
23
|
+
} catch (e2) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"macslap: Failed to load native module. " +
|
|
26
|
+
"Make sure you're on an Apple Silicon Mac (M1+) and ran npm install.\n" +
|
|
27
|
+
"This package requires macOS 14+ and must be run with sudo.\n" +
|
|
28
|
+
`Original error: ${e.message}`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================
|
|
34
|
+
// SlapDetector - Core class
|
|
35
|
+
// ============================================================
|
|
36
|
+
|
|
37
|
+
class SlapDetector extends EventEmitter {
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
super();
|
|
40
|
+
this._running = false;
|
|
41
|
+
this._cooldown = options.cooldown ?? 750; // ms between events
|
|
42
|
+
this._threshold = options.threshold ?? 0.05; // min force in g
|
|
43
|
+
this._lastEventTime = 0;
|
|
44
|
+
this._handlers = new Map(); // force-range -> [callbacks]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Start listening for slaps
|
|
49
|
+
*/
|
|
50
|
+
start() {
|
|
51
|
+
if (this._running) return this;
|
|
52
|
+
|
|
53
|
+
if (!SlapDetector.isSupported()) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"macslap: Apple Silicon accelerometer not found.\n" +
|
|
56
|
+
"Requirements:\n" +
|
|
57
|
+
" - Apple Silicon Mac (M1 Pro or M2+)\n" +
|
|
58
|
+
" - macOS 14+ (Sonoma)\n" +
|
|
59
|
+
" - Run with sudo\n" +
|
|
60
|
+
"Check with: ioreg -l -w0 | grep -A5 AppleSPUHIDDevice"
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
this._running = true;
|
|
65
|
+
|
|
66
|
+
native.start(
|
|
67
|
+
(event) => {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
if (now - this._lastEventTime < this._cooldown) return;
|
|
70
|
+
this._lastEventTime = now;
|
|
71
|
+
|
|
72
|
+
// Classify the event
|
|
73
|
+
const classified = this._classify(event);
|
|
74
|
+
|
|
75
|
+
// Emit typed events
|
|
76
|
+
this.emit("slap", classified);
|
|
77
|
+
this.emit(classified.type, classified);
|
|
78
|
+
|
|
79
|
+
// Fire registered handlers
|
|
80
|
+
this._fireHandlers(classified);
|
|
81
|
+
},
|
|
82
|
+
{ threshold: this._threshold }
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
this.emit("ready");
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Stop listening
|
|
91
|
+
*/
|
|
92
|
+
stop() {
|
|
93
|
+
if (!this._running) return this;
|
|
94
|
+
native.stop();
|
|
95
|
+
this._running = false;
|
|
96
|
+
this.emit("stop");
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read current accelerometer values
|
|
102
|
+
*/
|
|
103
|
+
read() {
|
|
104
|
+
return native.read();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Register a handler for a specific force range
|
|
109
|
+
*/
|
|
110
|
+
when(condition, callback) {
|
|
111
|
+
if (typeof condition === "function") {
|
|
112
|
+
// Custom condition function
|
|
113
|
+
const id = Symbol("handler");
|
|
114
|
+
this._handlers.set(id, { condition, callback });
|
|
115
|
+
return () => this._handlers.delete(id);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (typeof condition === "string") {
|
|
119
|
+
// Named intensity: "tap", "slap", "punch"
|
|
120
|
+
const ranges = {
|
|
121
|
+
tap: [0, 0.15],
|
|
122
|
+
slap: [0.15, 0.5],
|
|
123
|
+
hit: [0.5, 1.0],
|
|
124
|
+
punch: [1.0, Infinity],
|
|
125
|
+
};
|
|
126
|
+
const range = ranges[condition];
|
|
127
|
+
if (!range)
|
|
128
|
+
throw new Error(`Unknown intensity: ${condition}. Use: tap, slap, hit, punch`);
|
|
129
|
+
|
|
130
|
+
const id = Symbol("handler");
|
|
131
|
+
this._handlers.set(id, {
|
|
132
|
+
condition: (evt) => evt.force >= range[0] && evt.force < range[1],
|
|
133
|
+
callback,
|
|
134
|
+
});
|
|
135
|
+
return () => this._handlers.delete(id);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error("condition must be a string or function");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Classify event by force
|
|
143
|
+
*/
|
|
144
|
+
_classify(event) {
|
|
145
|
+
let type;
|
|
146
|
+
if (event.force < 0.15) type = "tap";
|
|
147
|
+
else if (event.force < 0.5) type = "slap";
|
|
148
|
+
else if (event.force < 1.0) type = "hit";
|
|
149
|
+
else type = "punch";
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
...event,
|
|
153
|
+
type,
|
|
154
|
+
timestamp: Date.now(),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Fire matching handlers
|
|
160
|
+
*/
|
|
161
|
+
_fireHandlers(event) {
|
|
162
|
+
for (const [, handler] of this._handlers) {
|
|
163
|
+
try {
|
|
164
|
+
if (handler.condition(event)) {
|
|
165
|
+
handler.callback(event);
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
this.emit("error", err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check if hardware is supported
|
|
175
|
+
*/
|
|
176
|
+
static isSupported() {
|
|
177
|
+
try {
|
|
178
|
+
return native.isSupported();
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ============================================================
|
|
186
|
+
// Convenience functions (the simple API)
|
|
187
|
+
// ============================================================
|
|
188
|
+
|
|
189
|
+
// Shared singleton detector
|
|
190
|
+
let _sharedDetector = null;
|
|
191
|
+
|
|
192
|
+
function getShared(options = {}) {
|
|
193
|
+
if (!_sharedDetector) {
|
|
194
|
+
_sharedDetector = new SlapDetector(options);
|
|
195
|
+
_sharedDetector.start();
|
|
196
|
+
|
|
197
|
+
// Clean up on process exit
|
|
198
|
+
const cleanup = () => {
|
|
199
|
+
if (_sharedDetector) {
|
|
200
|
+
_sharedDetector.stop();
|
|
201
|
+
_sharedDetector = null;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
process.on("exit", cleanup);
|
|
205
|
+
process.on("SIGINT", () => {
|
|
206
|
+
cleanup();
|
|
207
|
+
process.exit(0);
|
|
208
|
+
});
|
|
209
|
+
process.on("SIGTERM", () => {
|
|
210
|
+
cleanup();
|
|
211
|
+
process.exit(0);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return _sharedDetector;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* onSlap(callback, options?) - Run a function when the MacBook is slapped
|
|
219
|
+
*
|
|
220
|
+
* @param {Function} callback - Called with { x, y, z, force, type, timestamp }
|
|
221
|
+
* @param {Object} options - { cooldown, threshold }
|
|
222
|
+
* @returns {Function} unsubscribe function
|
|
223
|
+
*/
|
|
224
|
+
function onSlap(callback, options = {}) {
|
|
225
|
+
const detector = getShared(options);
|
|
226
|
+
detector.on("slap", callback);
|
|
227
|
+
return () => detector.off("slap", callback);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* onTap(callback, options?) - Run a function on gentle taps (force < 0.15g)
|
|
232
|
+
*/
|
|
233
|
+
function onTap(callback, options = {}) {
|
|
234
|
+
const detector = getShared(options);
|
|
235
|
+
detector.on("tap", callback);
|
|
236
|
+
return () => detector.off("tap", callback);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* onHit(callback, options?) - Run a function on hard hits (force > 0.5g)
|
|
241
|
+
*/
|
|
242
|
+
function onHit(callback, options = {}) {
|
|
243
|
+
const detector = getShared(options);
|
|
244
|
+
detector.on("hit", callback);
|
|
245
|
+
return () => detector.off("hit", callback);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* onPunch(callback, options?) - Run a function on very hard impacts (force > 1.0g)
|
|
250
|
+
*/
|
|
251
|
+
function onPunch(callback, options = {}) {
|
|
252
|
+
const detector = getShared(options);
|
|
253
|
+
detector.on("punch", callback);
|
|
254
|
+
return () => detector.off("punch", callback);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* onForce(min, max, callback, options?) - Run a function for a custom force range
|
|
259
|
+
*/
|
|
260
|
+
function onForce(min, max, callback, options = {}) {
|
|
261
|
+
const detector = getShared(options);
|
|
262
|
+
return detector.when((evt) => evt.force >= min && evt.force < max, callback);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* isSupported() - Check if hardware is available
|
|
267
|
+
*/
|
|
268
|
+
function isSupported() {
|
|
269
|
+
return SlapDetector.isSupported();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ============================================================
|
|
273
|
+
// Exports
|
|
274
|
+
// ============================================================
|
|
275
|
+
|
|
276
|
+
module.exports = {
|
|
277
|
+
// Simple API
|
|
278
|
+
onSlap,
|
|
279
|
+
onTap,
|
|
280
|
+
onHit,
|
|
281
|
+
onPunch,
|
|
282
|
+
onForce,
|
|
283
|
+
isSupported,
|
|
284
|
+
|
|
285
|
+
// Advanced API
|
|
286
|
+
SlapDetector,
|
|
287
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "macslap",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Detect physical slaps/taps on Apple Silicon MacBooks via the accelerometer. Run any function on slap.",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"install": "node-gyp rebuild",
|
|
9
|
+
"build": "node-gyp rebuild",
|
|
10
|
+
"test": "node examples/basic.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"macbook",
|
|
14
|
+
"slap",
|
|
15
|
+
"accelerometer",
|
|
16
|
+
"apple-silicon",
|
|
17
|
+
"iokit",
|
|
18
|
+
"hid",
|
|
19
|
+
"gesture",
|
|
20
|
+
"tap",
|
|
21
|
+
"motion",
|
|
22
|
+
"sensor",
|
|
23
|
+
"m1",
|
|
24
|
+
"m2",
|
|
25
|
+
"m3",
|
|
26
|
+
"m4",
|
|
27
|
+
"macos"
|
|
28
|
+
],
|
|
29
|
+
"author": "",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"os": [
|
|
32
|
+
"darwin"
|
|
33
|
+
],
|
|
34
|
+
"cpu": [
|
|
35
|
+
"arm64"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=16.0.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"node-addon-api": "^7.1.0",
|
|
42
|
+
"node-gyp": "^10.0.0"
|
|
43
|
+
},
|
|
44
|
+
"gypfile": true,
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": ""
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* macslap - Native accelerometer bindings for Apple Silicon MacBooks
|
|
3
|
+
*
|
|
4
|
+
* Reads the Bosch BMI286 IMU via IOKit HID (AppleSPUHIDDevice).
|
|
5
|
+
* The sensor lives at vendor usage page 0xFF00, usage 3.
|
|
6
|
+
* Data arrives as 22-byte HID reports at ~100Hz.
|
|
7
|
+
* X/Y/Z are int32 LE at byte offsets 6, 10, 14. Divide by 65536 for g.
|
|
8
|
+
*
|
|
9
|
+
* Requires sudo for IOKit HID access.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
#include <napi.h>
|
|
13
|
+
#include <IOKit/hid/IOHIDManager.h>
|
|
14
|
+
#include <IOKit/hid/IOHIDDevice.h>
|
|
15
|
+
#include <CoreFoundation/CoreFoundation.h>
|
|
16
|
+
#include <cmath>
|
|
17
|
+
#include <mutex>
|
|
18
|
+
#include <atomic>
|
|
19
|
+
#include <thread>
|
|
20
|
+
#include <functional>
|
|
21
|
+
|
|
22
|
+
// Sensor constants
|
|
23
|
+
static const uint32_t kVendorUsagePage = 0xFF00;
|
|
24
|
+
static const uint32_t kAccelUsage = 3;
|
|
25
|
+
static const size_t kReportSize = 22;
|
|
26
|
+
static const double kScaleFactor = 65536.0;
|
|
27
|
+
|
|
28
|
+
// State
|
|
29
|
+
static IOHIDDeviceRef g_device = nullptr;
|
|
30
|
+
static CFRunLoopRef g_runLoop = nullptr;
|
|
31
|
+
static std::thread g_sensorThread;
|
|
32
|
+
static std::atomic<bool> g_running{false};
|
|
33
|
+
static uint8_t g_reportBuffer[kReportSize];
|
|
34
|
+
|
|
35
|
+
// Callback data
|
|
36
|
+
static Napi::ThreadSafeFunction g_tsfn;
|
|
37
|
+
static std::mutex g_mutex;
|
|
38
|
+
|
|
39
|
+
// Last known acceleration
|
|
40
|
+
static std::atomic<double> g_lastX{0.0};
|
|
41
|
+
static std::atomic<double> g_lastY{0.0};
|
|
42
|
+
static std::atomic<double> g_lastZ{0.0};
|
|
43
|
+
|
|
44
|
+
// Vibration detection state
|
|
45
|
+
static double g_baselineX = 0.0;
|
|
46
|
+
static double g_baselineY = 0.0;
|
|
47
|
+
static double g_baselineZ = 0.0;
|
|
48
|
+
static bool g_baselineSet = false;
|
|
49
|
+
static int g_calibrationSamples = 0;
|
|
50
|
+
static const int kCalibrationCount = 50; // ~0.5s at 100Hz
|
|
51
|
+
static double g_sumX = 0.0, g_sumY = 0.0, g_sumZ = 0.0;
|
|
52
|
+
|
|
53
|
+
struct SlapEvent {
|
|
54
|
+
double x, y, z;
|
|
55
|
+
double force; // magnitude of deviation from baseline
|
|
56
|
+
double timestamp;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* HID input report callback - fires at ~100Hz
|
|
61
|
+
*/
|
|
62
|
+
static void hidReportCallback(
|
|
63
|
+
void *context,
|
|
64
|
+
IOReturn result,
|
|
65
|
+
void *sender,
|
|
66
|
+
IOHIDReportType type,
|
|
67
|
+
uint32_t reportID,
|
|
68
|
+
uint8_t *report,
|
|
69
|
+
CFIndex reportLength) {
|
|
70
|
+
|
|
71
|
+
if (reportLength < (CFIndex)kReportSize) return;
|
|
72
|
+
|
|
73
|
+
// Parse x/y/z from report (int32 LE at offsets 6, 10, 14)
|
|
74
|
+
int32_t rawX, rawY, rawZ;
|
|
75
|
+
memcpy(&rawX, report + 6, 4);
|
|
76
|
+
memcpy(&rawY, report + 10, 4);
|
|
77
|
+
memcpy(&rawZ, report + 14, 4);
|
|
78
|
+
|
|
79
|
+
double x = (double)rawX / kScaleFactor;
|
|
80
|
+
double y = (double)rawY / kScaleFactor;
|
|
81
|
+
double z = (double)rawZ / kScaleFactor;
|
|
82
|
+
|
|
83
|
+
g_lastX.store(x);
|
|
84
|
+
g_lastY.store(y);
|
|
85
|
+
g_lastZ.store(z);
|
|
86
|
+
|
|
87
|
+
// Calibration phase: build baseline from first N samples
|
|
88
|
+
if (!g_baselineSet) {
|
|
89
|
+
g_sumX += x;
|
|
90
|
+
g_sumY += y;
|
|
91
|
+
g_sumZ += z;
|
|
92
|
+
g_calibrationSamples++;
|
|
93
|
+
if (g_calibrationSamples >= kCalibrationCount) {
|
|
94
|
+
g_baselineX = g_sumX / kCalibrationCount;
|
|
95
|
+
g_baselineY = g_sumY / kCalibrationCount;
|
|
96
|
+
g_baselineZ = g_sumZ / kCalibrationCount;
|
|
97
|
+
g_baselineSet = true;
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Compute deviation from baseline
|
|
103
|
+
double dx = x - g_baselineX;
|
|
104
|
+
double dy = y - g_baselineY;
|
|
105
|
+
double dz = z - g_baselineZ;
|
|
106
|
+
double force = sqrt(dx * dx + dy * dy + dz * dz);
|
|
107
|
+
|
|
108
|
+
// Get threshold from context (default 0.05g)
|
|
109
|
+
double threshold = 0.05;
|
|
110
|
+
if (context) {
|
|
111
|
+
threshold = *(double *)context;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (force > threshold) {
|
|
115
|
+
// Fire the JS callback
|
|
116
|
+
auto event = new SlapEvent{x, y, z, force,
|
|
117
|
+
(double)clock() / CLOCKS_PER_SEC};
|
|
118
|
+
|
|
119
|
+
g_tsfn.NonBlockingCall(event,
|
|
120
|
+
[](Napi::Env env, Napi::Function jsCallback, SlapEvent *evt) {
|
|
121
|
+
Napi::Object obj = Napi::Object::New(env);
|
|
122
|
+
obj.Set("x", Napi::Number::New(env, evt->x));
|
|
123
|
+
obj.Set("y", Napi::Number::New(env, evt->y));
|
|
124
|
+
obj.Set("z", Napi::Number::New(env, evt->z));
|
|
125
|
+
obj.Set("force", Napi::Number::New(env, evt->force));
|
|
126
|
+
obj.Set("timestamp", Napi::Number::New(env, evt->timestamp));
|
|
127
|
+
jsCallback.Call({obj});
|
|
128
|
+
delete evt;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Update baseline slowly (adaptive)
|
|
132
|
+
g_baselineX = g_baselineX * 0.99 + x * 0.01;
|
|
133
|
+
g_baselineY = g_baselineY * 0.99 + y * 0.01;
|
|
134
|
+
g_baselineZ = g_baselineZ * 0.99 + z * 0.01;
|
|
135
|
+
} else {
|
|
136
|
+
// Update baseline faster when quiet
|
|
137
|
+
g_baselineX = g_baselineX * 0.95 + x * 0.05;
|
|
138
|
+
g_baselineY = g_baselineY * 0.95 + y * 0.05;
|
|
139
|
+
g_baselineZ = g_baselineZ * 0.95 + z * 0.05;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Find and open the AppleSPUHIDDevice accelerometer
|
|
145
|
+
*/
|
|
146
|
+
static IOHIDDeviceRef findAccelerometer() {
|
|
147
|
+
IOHIDManagerRef manager = IOHIDManagerCreate(
|
|
148
|
+
kCFAllocatorDefault, kIOHIDOptionsTypeNone);
|
|
149
|
+
if (!manager) return nullptr;
|
|
150
|
+
|
|
151
|
+
// Match on vendor usage page 0xFF00, usage 3 (accelerometer)
|
|
152
|
+
CFMutableDictionaryRef matching = CFDictionaryCreateMutable(
|
|
153
|
+
kCFAllocatorDefault, 0,
|
|
154
|
+
&kCFTypeDictionaryKeyCallBacks,
|
|
155
|
+
&kCFTypeDictionaryValueCallBacks);
|
|
156
|
+
|
|
157
|
+
int usagePage = kVendorUsagePage;
|
|
158
|
+
int usage = kAccelUsage;
|
|
159
|
+
CFNumberRef cfUsagePage = CFNumberCreate(
|
|
160
|
+
kCFAllocatorDefault, kCFNumberIntType, &usagePage);
|
|
161
|
+
CFNumberRef cfUsage = CFNumberCreate(
|
|
162
|
+
kCFAllocatorDefault, kCFNumberIntType, &usage);
|
|
163
|
+
|
|
164
|
+
CFDictionarySetValue(matching,
|
|
165
|
+
CFSTR(kIOHIDDeviceUsagePageKey), cfUsagePage);
|
|
166
|
+
CFDictionarySetValue(matching,
|
|
167
|
+
CFSTR(kIOHIDDeviceUsageKey), cfUsage);
|
|
168
|
+
|
|
169
|
+
IOHIDManagerSetDeviceMatching(manager, matching);
|
|
170
|
+
IOHIDManagerScheduleWithRunLoop(
|
|
171
|
+
manager, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
|
|
172
|
+
IOHIDManagerOpen(manager, kIOHIDOptionsTypeNone);
|
|
173
|
+
|
|
174
|
+
CFSetRef deviceSet = IOHIDManagerCopyDevices(manager);
|
|
175
|
+
IOHIDDeviceRef device = nullptr;
|
|
176
|
+
|
|
177
|
+
if (deviceSet) {
|
|
178
|
+
CFIndex count = CFSetGetCount(deviceSet);
|
|
179
|
+
if (count > 0) {
|
|
180
|
+
IOHIDDeviceRef *devices = new IOHIDDeviceRef[count];
|
|
181
|
+
CFSetGetValues(deviceSet, (const void **)devices);
|
|
182
|
+
device = devices[0];
|
|
183
|
+
CFRetain(device);
|
|
184
|
+
delete[] devices;
|
|
185
|
+
}
|
|
186
|
+
CFRelease(deviceSet);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
CFRelease(cfUsagePage);
|
|
190
|
+
CFRelease(cfUsage);
|
|
191
|
+
CFRelease(matching);
|
|
192
|
+
// Don't release manager — we need it alive for the run loop
|
|
193
|
+
|
|
194
|
+
return device;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Sensor reading thread
|
|
199
|
+
*/
|
|
200
|
+
static void sensorThreadFunc(double threshold) {
|
|
201
|
+
g_device = findAccelerometer();
|
|
202
|
+
if (!g_device) {
|
|
203
|
+
// Signal error back to JS
|
|
204
|
+
g_running.store(false);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Open the device
|
|
209
|
+
IOReturn ret = IOHIDDeviceOpen(g_device, kIOHIDOptionsTypeNone);
|
|
210
|
+
if (ret != kIOReturnSuccess) {
|
|
211
|
+
CFRelease(g_device);
|
|
212
|
+
g_device = nullptr;
|
|
213
|
+
g_running.store(false);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Store threshold for callback
|
|
218
|
+
static double storedThreshold;
|
|
219
|
+
storedThreshold = threshold;
|
|
220
|
+
|
|
221
|
+
// Register input report callback
|
|
222
|
+
IOHIDDeviceRegisterInputReportCallback(
|
|
223
|
+
g_device,
|
|
224
|
+
g_reportBuffer,
|
|
225
|
+
kReportSize,
|
|
226
|
+
hidReportCallback,
|
|
227
|
+
&storedThreshold);
|
|
228
|
+
|
|
229
|
+
// Schedule with run loop
|
|
230
|
+
IOHIDDeviceScheduleWithRunLoop(
|
|
231
|
+
g_device, CFRunLoopGetCurrent(), kCFRunLoopDefaultMode);
|
|
232
|
+
|
|
233
|
+
g_runLoop = CFRunLoopGetCurrent();
|
|
234
|
+
|
|
235
|
+
// Run until stopped
|
|
236
|
+
while (g_running.load()) {
|
|
237
|
+
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, false);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Cleanup
|
|
241
|
+
IOHIDDeviceClose(g_device, kIOHIDOptionsTypeNone);
|
|
242
|
+
CFRelease(g_device);
|
|
243
|
+
g_device = nullptr;
|
|
244
|
+
g_runLoop = nullptr;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ============================================================
|
|
248
|
+
// N-API exports
|
|
249
|
+
// ============================================================
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* start(callback, options?) - Begin listening for slaps
|
|
253
|
+
*
|
|
254
|
+
* Options:
|
|
255
|
+
* threshold: number (default 0.05) - minimum force in g to trigger
|
|
256
|
+
*/
|
|
257
|
+
Napi::Value Start(const Napi::CallbackInfo &info) {
|
|
258
|
+
Napi::Env env = info.Env();
|
|
259
|
+
|
|
260
|
+
if (g_running.load()) {
|
|
261
|
+
Napi::Error::New(env, "macslap is already running. Call stop() first.")
|
|
262
|
+
.ThrowAsJavaScriptException();
|
|
263
|
+
return env.Undefined();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (info.Length() < 1 || !info[0].IsFunction()) {
|
|
267
|
+
Napi::TypeError::New(env, "Expected a callback function as first argument")
|
|
268
|
+
.ThrowAsJavaScriptException();
|
|
269
|
+
return env.Undefined();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
double threshold = 0.05;
|
|
273
|
+
if (info.Length() > 1 && info[1].IsObject()) {
|
|
274
|
+
Napi::Object opts = info[1].As<Napi::Object>();
|
|
275
|
+
if (opts.Has("threshold")) {
|
|
276
|
+
threshold = opts.Get("threshold").As<Napi::Number>().DoubleValue();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Reset calibration
|
|
281
|
+
g_baselineSet = false;
|
|
282
|
+
g_calibrationSamples = 0;
|
|
283
|
+
g_sumX = g_sumY = g_sumZ = 0.0;
|
|
284
|
+
|
|
285
|
+
// Create thread-safe function for calling JS from sensor thread
|
|
286
|
+
g_tsfn = Napi::ThreadSafeFunction::New(
|
|
287
|
+
env,
|
|
288
|
+
info[0].As<Napi::Function>(),
|
|
289
|
+
"macslap_callback",
|
|
290
|
+
0, // unlimited queue
|
|
291
|
+
1); // 1 thread
|
|
292
|
+
|
|
293
|
+
g_running.store(true);
|
|
294
|
+
g_sensorThread = std::thread(sensorThreadFunc, threshold);
|
|
295
|
+
|
|
296
|
+
return env.Undefined();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* stop() - Stop listening for slaps
|
|
301
|
+
*/
|
|
302
|
+
Napi::Value Stop(const Napi::CallbackInfo &info) {
|
|
303
|
+
Napi::Env env = info.Env();
|
|
304
|
+
|
|
305
|
+
if (!g_running.load()) {
|
|
306
|
+
return env.Undefined();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
g_running.store(false);
|
|
310
|
+
|
|
311
|
+
// Stop the run loop
|
|
312
|
+
if (g_runLoop) {
|
|
313
|
+
CFRunLoopStop(g_runLoop);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (g_sensorThread.joinable()) {
|
|
317
|
+
g_sensorThread.join();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
g_tsfn.Release();
|
|
321
|
+
|
|
322
|
+
return env.Undefined();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* read() - Get current accelerometer values (one-shot)
|
|
327
|
+
*/
|
|
328
|
+
Napi::Value Read(const Napi::CallbackInfo &info) {
|
|
329
|
+
Napi::Env env = info.Env();
|
|
330
|
+
|
|
331
|
+
Napi::Object obj = Napi::Object::New(env);
|
|
332
|
+
obj.Set("x", Napi::Number::New(env, g_lastX.load()));
|
|
333
|
+
obj.Set("y", Napi::Number::New(env, g_lastY.load()));
|
|
334
|
+
obj.Set("z", Napi::Number::New(env, g_lastZ.load()));
|
|
335
|
+
obj.Set("running", Napi::Boolean::New(env, g_running.load()));
|
|
336
|
+
obj.Set("calibrated", Napi::Boolean::New(env, g_baselineSet));
|
|
337
|
+
|
|
338
|
+
return obj;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* isSupported() - Check if the accelerometer is available
|
|
343
|
+
*/
|
|
344
|
+
Napi::Value IsSupported(const Napi::CallbackInfo &info) {
|
|
345
|
+
Napi::Env env = info.Env();
|
|
346
|
+
|
|
347
|
+
IOHIDDeviceRef device = findAccelerometer();
|
|
348
|
+
bool supported = (device != nullptr);
|
|
349
|
+
if (device) CFRelease(device);
|
|
350
|
+
|
|
351
|
+
return Napi::Boolean::New(env, supported);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Module init
|
|
356
|
+
*/
|
|
357
|
+
Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
358
|
+
exports.Set("start", Napi::Function::New(env, Start));
|
|
359
|
+
exports.Set("stop", Napi::Function::New(env, Stop));
|
|
360
|
+
exports.Set("read", Napi::Function::New(env, Read));
|
|
361
|
+
exports.Set("isSupported", Napi::Function::New(env, IsSupported));
|
|
362
|
+
return exports;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
NODE_API_MODULE(macslap_native, Init)
|