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 ADDED
@@ -0,0 +1,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
package/README.md ADDED
@@ -0,0 +1,2 @@
1
+ # node-surface-dial
2
+ A NodeRED module for the Microsoft Surface Dial
@@ -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
+ }