node-red-contrib-surface-dial 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/.gitattributes +2 -0
- package/README.md +2 -0
- package/example-flows/OnkyoVolumeControl.json +198 -0
- package/nodes/surface-dial.html +41 -0
- package/nodes/surface-dial.js +180 -0
- package/package.json +25 -0
package/.gitattributes
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "525341509fb3d38a",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "Flow 1",
|
|
6
|
+
"disabled": false,
|
|
7
|
+
"info": "",
|
|
8
|
+
"env": []
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "346aa6a9bcdd187a",
|
|
12
|
+
"type": "change",
|
|
13
|
+
"z": "525341509fb3d38a",
|
|
14
|
+
"name": "Toggle Mute",
|
|
15
|
+
"rules": [
|
|
16
|
+
{
|
|
17
|
+
"t": "set",
|
|
18
|
+
"p": "payload",
|
|
19
|
+
"pt": "msg",
|
|
20
|
+
"to": "AMTTG",
|
|
21
|
+
"tot": "str"
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"action": "",
|
|
25
|
+
"property": "",
|
|
26
|
+
"from": "",
|
|
27
|
+
"to": "",
|
|
28
|
+
"reg": false,
|
|
29
|
+
"x": 570,
|
|
30
|
+
"y": 300,
|
|
31
|
+
"wires": [
|
|
32
|
+
[
|
|
33
|
+
"fdc88824dc09574d"
|
|
34
|
+
]
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "3d6fc401573ee75d",
|
|
39
|
+
"type": "change",
|
|
40
|
+
"z": "525341509fb3d38a",
|
|
41
|
+
"name": "Volume Up",
|
|
42
|
+
"rules": [
|
|
43
|
+
{
|
|
44
|
+
"t": "set",
|
|
45
|
+
"p": "payload",
|
|
46
|
+
"pt": "msg",
|
|
47
|
+
"to": "MVLUP",
|
|
48
|
+
"tot": "str"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"action": "",
|
|
52
|
+
"property": "",
|
|
53
|
+
"from": "",
|
|
54
|
+
"to": "",
|
|
55
|
+
"reg": false,
|
|
56
|
+
"x": 570,
|
|
57
|
+
"y": 180,
|
|
58
|
+
"wires": [
|
|
59
|
+
[
|
|
60
|
+
"fdc88824dc09574d"
|
|
61
|
+
]
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": "1a7aa7895b7cf865",
|
|
66
|
+
"type": "change",
|
|
67
|
+
"z": "525341509fb3d38a",
|
|
68
|
+
"name": "Volume Down",
|
|
69
|
+
"rules": [
|
|
70
|
+
{
|
|
71
|
+
"t": "set",
|
|
72
|
+
"p": "payload",
|
|
73
|
+
"pt": "msg",
|
|
74
|
+
"to": "MVLDOWN",
|
|
75
|
+
"tot": "str"
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
"action": "",
|
|
79
|
+
"property": "",
|
|
80
|
+
"from": "",
|
|
81
|
+
"to": "",
|
|
82
|
+
"reg": false,
|
|
83
|
+
"x": 580,
|
|
84
|
+
"y": 240,
|
|
85
|
+
"wires": [
|
|
86
|
+
[
|
|
87
|
+
"fdc88824dc09574d"
|
|
88
|
+
]
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"id": "fdc88824dc09574d",
|
|
93
|
+
"type": "function",
|
|
94
|
+
"z": "525341509fb3d38a",
|
|
95
|
+
"name": "Onkyo EISCP",
|
|
96
|
+
"func": "function buildEiscpPacket(command) {\n // Payload: !1<COMMAND>\\r\n const payload = Buffer.from(`!1${command}\\r`, \"ascii\");\n\n const header = Buffer.alloc(16);\n\n // \"ISCP\"\n header.write(\"ISCP\", 0, 4, \"ascii\");\n\n // Header size = 16\n header.writeUInt32BE(16, 4);\n\n // Data size = payload length\n header.writeUInt32BE(payload.length, 8);\n\n // Version = 1\n header[12] = 0x01;\n // bytes 13–15 stay 0\n\n return Buffer.concat([header, payload]);\n}\n\n\nmsg.payload = buildEiscpPacket(msg.payload)\nreturn msg",
|
|
97
|
+
"outputs": 1,
|
|
98
|
+
"timeout": 0,
|
|
99
|
+
"noerr": 0,
|
|
100
|
+
"initialize": "",
|
|
101
|
+
"finalize": "",
|
|
102
|
+
"libs": [],
|
|
103
|
+
"x": 820,
|
|
104
|
+
"y": 240,
|
|
105
|
+
"wires": [
|
|
106
|
+
[
|
|
107
|
+
"2738e6759d4a0f23"
|
|
108
|
+
]
|
|
109
|
+
]
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"id": "2738e6759d4a0f23",
|
|
113
|
+
"type": "tcp request",
|
|
114
|
+
"z": "525341509fb3d38a",
|
|
115
|
+
"name": "",
|
|
116
|
+
"server": "192.168.60.187",
|
|
117
|
+
"port": "60128",
|
|
118
|
+
"out": "sit",
|
|
119
|
+
"ret": "buffer",
|
|
120
|
+
"splitc": " ",
|
|
121
|
+
"newline": "",
|
|
122
|
+
"trim": false,
|
|
123
|
+
"tls": "",
|
|
124
|
+
"x": 1060,
|
|
125
|
+
"y": 240,
|
|
126
|
+
"wires": [
|
|
127
|
+
[]
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "d32a4762b7f63361",
|
|
132
|
+
"type": "surface-dial",
|
|
133
|
+
"z": "525341509fb3d38a",
|
|
134
|
+
"name": "",
|
|
135
|
+
"x": 170,
|
|
136
|
+
"y": 240,
|
|
137
|
+
"wires": [
|
|
138
|
+
[
|
|
139
|
+
"b2c18fde179a4291"
|
|
140
|
+
]
|
|
141
|
+
]
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"id": "b2c18fde179a4291",
|
|
145
|
+
"type": "switch",
|
|
146
|
+
"z": "525341509fb3d38a",
|
|
147
|
+
"name": "Event Type",
|
|
148
|
+
"property": "payload",
|
|
149
|
+
"propertyType": "msg",
|
|
150
|
+
"rules": [
|
|
151
|
+
{
|
|
152
|
+
"t": "eq",
|
|
153
|
+
"v": "clockwise",
|
|
154
|
+
"vt": "str"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"t": "eq",
|
|
158
|
+
"v": "counter-clockwise",
|
|
159
|
+
"vt": "str"
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"t": "eq",
|
|
163
|
+
"v": "pressed",
|
|
164
|
+
"vt": "str"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"t": "eq",
|
|
168
|
+
"v": "released",
|
|
169
|
+
"vt": "str"
|
|
170
|
+
}
|
|
171
|
+
],
|
|
172
|
+
"checkall": "true",
|
|
173
|
+
"repair": false,
|
|
174
|
+
"outputs": 4,
|
|
175
|
+
"x": 350,
|
|
176
|
+
"y": 240,
|
|
177
|
+
"wires": [
|
|
178
|
+
[
|
|
179
|
+
"3d6fc401573ee75d"
|
|
180
|
+
],
|
|
181
|
+
[
|
|
182
|
+
"1a7aa7895b7cf865"
|
|
183
|
+
],
|
|
184
|
+
[
|
|
185
|
+
"346aa6a9bcdd187a"
|
|
186
|
+
],
|
|
187
|
+
[]
|
|
188
|
+
]
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"id": "5702f84b15c52c98",
|
|
192
|
+
"type": "global-config",
|
|
193
|
+
"env": [],
|
|
194
|
+
"modules": {
|
|
195
|
+
"node-red-contrib-surface-dial": "1.0.0"
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('surface-dial', {
|
|
3
|
+
category: 'input',
|
|
4
|
+
color: '#0078D4',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" }
|
|
7
|
+
},
|
|
8
|
+
inputs: 0,
|
|
9
|
+
outputs: 1,
|
|
10
|
+
icon: "font-awesome/fa-circle-o-notch",
|
|
11
|
+
label: function() {
|
|
12
|
+
return this.name || "surface-dial";
|
|
13
|
+
},
|
|
14
|
+
paletteLabel: "surface-dial"
|
|
15
|
+
});
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script type="text/html" data-template-name="surface-dial">
|
|
19
|
+
<div class="form-row">
|
|
20
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
21
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
22
|
+
</div>
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<script type="text/html" data-help-name="surface-dial">
|
|
26
|
+
<p>Discovers a local Microsoft Surface Dial, connects to it, and emits button and rotation events.</p>
|
|
27
|
+
|
|
28
|
+
<h3>Outputs</h3>
|
|
29
|
+
<dl class="message-properties">
|
|
30
|
+
<dt>topic <span class="property-type">string</span></dt>
|
|
31
|
+
<dd><code>"button"</code> or <code>"rotation"</code></dd>
|
|
32
|
+
<dt>payload <span class="property-type">string</span></dt>
|
|
33
|
+
<dd>
|
|
34
|
+
<code>"pressed"</code> / <code>"released"</code> for button events<br>
|
|
35
|
+
<code>"clockwise"</code> / <code>"counter-clockwise"</code> for rotation events
|
|
36
|
+
</dd>
|
|
37
|
+
</dl>
|
|
38
|
+
|
|
39
|
+
<h3>Details</h3>
|
|
40
|
+
<p>Auto-connects to the Surface Dial on deploy and reconnects if disconnected.</p>
|
|
41
|
+
</script>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
const HID = require('node-hid');
|
|
3
|
+
|
|
4
|
+
// Surface Dial constants
|
|
5
|
+
const VENDOR_ID = 0x045E; // Microsoft
|
|
6
|
+
const PRODUCT_ID = 0x091B; // Surface Dial
|
|
7
|
+
const RECONNECT_POLL_INTERVAL = 50; // ms
|
|
8
|
+
|
|
9
|
+
function SurfaceDialNode(config) {
|
|
10
|
+
RED.nodes.createNode(this, config);
|
|
11
|
+
const node = this;
|
|
12
|
+
|
|
13
|
+
let device = null;
|
|
14
|
+
let pollTimer = null;
|
|
15
|
+
let prevButtonState = false;
|
|
16
|
+
let closing = false;
|
|
17
|
+
|
|
18
|
+
// Attempt to find and open the Surface Dial
|
|
19
|
+
// Returns the device handle or null if not found
|
|
20
|
+
function connectDevice() {
|
|
21
|
+
try {
|
|
22
|
+
const devices = HID.devices(VENDOR_ID, PRODUCT_ID);
|
|
23
|
+
if (devices.length === 0) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const deviceInfo = devices[0];
|
|
28
|
+
const dev = new HID.HID(deviceInfo.path);
|
|
29
|
+
|
|
30
|
+
node.log(`Found Surface Dial: ${deviceInfo.product || 'Unknown'} at ${deviceInfo.path}`);
|
|
31
|
+
return dev;
|
|
32
|
+
} catch (err) {
|
|
33
|
+
node.log(`Failed to connect: ${err.message}`);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Parse HID report data
|
|
39
|
+
function parseReport(data) {
|
|
40
|
+
if (data.length < 4) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check Report ID (must be 1)
|
|
45
|
+
const reportID = data[0];
|
|
46
|
+
if (reportID !== 1) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Parse button state (byte 1, bit 0)
|
|
51
|
+
const buttonPressed = (data[1] & 0x01) !== 0;
|
|
52
|
+
|
|
53
|
+
// Detect button state transitions
|
|
54
|
+
if (buttonPressed !== prevButtonState) {
|
|
55
|
+
const state = buttonPressed ? "pressed" : "released";
|
|
56
|
+
node.send({
|
|
57
|
+
payload: state,
|
|
58
|
+
topic: "button"
|
|
59
|
+
});
|
|
60
|
+
prevButtonState = buttonPressed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Parse rotation value (bytes 2-3, little-endian signed int16)
|
|
64
|
+
let rotation = data[2] | (data[3] << 8);
|
|
65
|
+
// Convert to signed int16
|
|
66
|
+
if (rotation > 32767) {
|
|
67
|
+
rotation -= 65536;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (rotation !== 0) {
|
|
71
|
+
const direction = rotation > 0 ? "clockwise" : "counter-clockwise";
|
|
72
|
+
node.send({
|
|
73
|
+
payload: direction,
|
|
74
|
+
topic: "rotation"
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Start listening for device events
|
|
80
|
+
function startEventLoop() {
|
|
81
|
+
if (!device || closing) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
device.on('data', (data) => {
|
|
86
|
+
parseReport(data);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
device.on('error', (err) => {
|
|
90
|
+
if (closing) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
node.log(`Device error: ${err.message}`);
|
|
94
|
+
handleDisconnect();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Handle device disconnection
|
|
99
|
+
function handleDisconnect() {
|
|
100
|
+
if (closing) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (device) {
|
|
105
|
+
try {
|
|
106
|
+
device.close();
|
|
107
|
+
} catch (e) {
|
|
108
|
+
// Ignore close errors
|
|
109
|
+
}
|
|
110
|
+
device = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
prevButtonState = false;
|
|
114
|
+
node.status({ fill: "red", shape: "ring", text: "disconnected" });
|
|
115
|
+
|
|
116
|
+
// Start polling for reconnection
|
|
117
|
+
waitForDevice();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Poll for device until available or node is closed
|
|
121
|
+
function waitForDevice() {
|
|
122
|
+
if (closing) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Try immediate connection first
|
|
127
|
+
device = connectDevice();
|
|
128
|
+
if (device) {
|
|
129
|
+
node.status({ fill: "green", shape: "dot", text: "connected" });
|
|
130
|
+
startEventLoop();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Start polling
|
|
135
|
+
node.status({ fill: "grey", shape: "ring", text: "waiting..." });
|
|
136
|
+
|
|
137
|
+
pollTimer = setInterval(() => {
|
|
138
|
+
if (closing) {
|
|
139
|
+
clearInterval(pollTimer);
|
|
140
|
+
pollTimer = null;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
device = connectDevice();
|
|
145
|
+
if (device) {
|
|
146
|
+
clearInterval(pollTimer);
|
|
147
|
+
pollTimer = null;
|
|
148
|
+
node.status({ fill: "green", shape: "dot", text: "connected" });
|
|
149
|
+
startEventLoop();
|
|
150
|
+
}
|
|
151
|
+
}, RECONNECT_POLL_INTERVAL);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Start connection loop on deploy
|
|
155
|
+
waitForDevice();
|
|
156
|
+
|
|
157
|
+
// Cleanup on node close
|
|
158
|
+
node.on('close', (done) => {
|
|
159
|
+
closing = true;
|
|
160
|
+
|
|
161
|
+
if (pollTimer) {
|
|
162
|
+
clearInterval(pollTimer);
|
|
163
|
+
pollTimer = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (device) {
|
|
167
|
+
try {
|
|
168
|
+
device.close();
|
|
169
|
+
} catch (e) {
|
|
170
|
+
// Ignore close errors
|
|
171
|
+
}
|
|
172
|
+
device = null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
done();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
RED.nodes.registerType("surface-dial", SurfaceDialNode);
|
|
180
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-surface-dial",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Node-RED node for Microsoft Surface Dial",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"node-red",
|
|
7
|
+
"surface-dial",
|
|
8
|
+
"microsoft",
|
|
9
|
+
"hid"
|
|
10
|
+
],
|
|
11
|
+
"author": "",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"node-red": {
|
|
14
|
+
"version": ">=2.0.0",
|
|
15
|
+
"nodes": {
|
|
16
|
+
"surface-dial": "nodes/surface-dial.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"node-hid": "^3.1.0"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=14.0.0"
|
|
24
|
+
}
|
|
25
|
+
}
|