cncjs-pendant-alexa 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cassio Simoes
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,147 @@
1
+ # cncjs-pendant-alexa
2
+
3
+ Voice jog control for [cncjs](https://github.com/cncjs/cncjs) through a custom
4
+ Alexa skill:
5
+
6
+ > One-shot: "Alexa, **ask mill x plus 20**" — or open CNC mode: "Alexa,
7
+ > open **mill**" … "**x plus 20**" … "**position x 130 y 100**" … "**close**"
8
+ >
9
+ > One-shot: "Alexa, **peça pra fresa xis mais vinte**" — ou modo CNC:
10
+ > "Alexa, abrir **fresa**" … "**xis mais vinte**" … "**fechar**"
11
+
12
+ Numbers are millimeters. The pendant connects to the cncjs server's socket.io
13
+ API (like any other `cncjs-pendant-*`), sends Grbl `$J=` jog commands and
14
+ exposes a small HTTPS-tunneled endpoint that your own Alexa developer skill
15
+ calls. No cloud service in the middle besides Alexa itself; no skill-store
16
+ publication needed (a development-mode skill works forever on the Echos of
17
+ your Amazon account, for free).
18
+
19
+ Interaction models for **en-US** and **pt-BR** are included, ready to paste.
20
+
21
+ ## Safety model
22
+
23
+ Voice is a lossy input, so the pendant refuses instead of trusting:
24
+
25
+ - Only `$J=` jog commands are ever sent — cancellable instantly, no parser
26
+ state pollution. Never raw `G0/G1`.
27
+ - Per-command travel limits (default: 50 mm on X/Y, 10 mm on Z). Asking for
28
+ 200 mm gets a spoken refusal, not a move.
29
+ - Jogs are refused unless the controller is `Idle`/`Jog` **and** the cncjs
30
+ workflow is idle — a running job can never be disturbed.
31
+ - `Alarm` is reported, never auto-unlocked by voice.
32
+ - Z uses a slower feed rate than X/Y (defaults: F300 vs F1000).
33
+ - "halt" / "para" (and exiting the skill) send Grbl jog-cancel (`0x85`).
34
+ - Every request is cryptographically verified as coming from Alexa
35
+ (certificate + timestamp) and, with `--skill-id`, from *your* skill only.
36
+
37
+ ## Install
38
+
39
+ ```
40
+ npm install -g thcasssio/cncjs-pendant-alexa
41
+ ```
42
+
43
+ (Installs straight from GitHub; an npm registry release will follow.)
44
+
45
+ Run it on the same host as the cncjs server (it reads the `secret` from the
46
+ server's `~/.cncrc` to authenticate, exactly like the other community
47
+ pendants):
48
+
49
+ ```
50
+ cncjs-pendant-alexa --port /dev/ttyUSB0 --skill-id amzn1.ask.skill.xxxx
51
+ ```
52
+
53
+ Options: `--baudrate 115200`, `--cncjs-url ws://127.0.0.1:8000`,
54
+ `--http-port 8977`, `--http-path /alexa`, `--secret-file /home/cnc/.cncrc`,
55
+ `--max-xy 50 --max-z 10 --feed-xy 1000 --feed-z 300`,
56
+ `--controller-type Grbl`.
57
+
58
+ Sanity check (connects, prints machine state as JSON, exits):
59
+
60
+ ```
61
+ cncjs-pendant-alexa --port /dev/ttyUSB0 --check
62
+ ```
63
+
64
+ ## Set up the Alexa skill (one time, ~15 min, free)
65
+
66
+ 1. **HTTPS tunnel** — Alexa must reach your endpoint over public HTTPS with a
67
+ valid certificate. Easiest free option: [ngrok](https://ngrok.com) with the
68
+ one free **static domain** every account gets
69
+ (`ngrok http 8977 --url=yourname.ngrok-free.app`). Alternatives: Cloudflare
70
+ Tunnel (if you own a domain) or Tailscale Funnel. See
71
+ `examples/systemd/alexa-tunnel-ngrok.service`.
72
+ 2. Go to the [Alexa developer console](https://developer.amazon.com/alexa/console/ask)
73
+ → **Create Skill** → name it (e.g. "CNC Pendant") → primary locale
74
+ **English (US)** or **Portuguese (BR)** → type **Custom** → host
75
+ **Provision your own**.
76
+ 3. In **Build → Interaction Model → JSON Editor**, paste
77
+ [`models/en-US.json`](models/en-US.json) or
78
+ [`models/pt-BR.json`](models/pt-BR.json) (add the other locale under
79
+ **Language Settings** if you want both) → **Save Model** → **Build Model**.
80
+ 4. In **Build → Endpoint**, choose **HTTPS**, set the default region to
81
+ `https://yourname.ngrok-free.app/alexa`, and pick the SSL certificate type
82
+ that matches your host — **this choice matters and the errors it causes are
83
+ silent**:
84
+ - Behind **Cloudflare** (or any host whose certificate covers your
85
+ subdomain via a wildcard like `*.example.com`): pick *"My development
86
+ endpoint is a sub-domain of a domain that has a wildcard certificate"*
87
+ (`Wildcard`).
88
+ - Only if the certificate names your exact hostname: *"...has a certificate
89
+ from a trusted certificate authority"* (`Trusted`).
90
+
91
+ With the wrong type, Alexa rejects the TLS handshake before any request
92
+ reaches you, and the simulator only says "endpoint error". The
93
+ [skill invocation API](https://developer.amazon.com/en-US/docs/alexa/smapi/skill-invocation-api.html)
94
+ is the one tool that reports the real reason (e.g. *"Certificate for host
95
+ contains wildcard"*).
96
+ 5. Copy **Your Skill ID** (top of the Endpoint page) and pass it to the
97
+ pendant via `--skill-id`.
98
+ 6. In **Test**, enable testing in **Development**. The skill is now live on
99
+ every Echo logged into your account — no certification, no publication.
100
+
101
+ ## Run as a service
102
+
103
+ Copy the units from `examples/systemd/`, edit port/skill-id/domain:
104
+
105
+ ```
106
+ sudo cp examples/systemd/*.service /etc/systemd/system/
107
+ sudo systemctl daemon-reload
108
+ sudo systemctl enable --now cncjs-pendant-alexa alexa-tunnel-ngrok
109
+ ```
110
+
111
+ `GET /healthz` on the HTTP port reports the live machine state for monitoring.
112
+
113
+ ## Voice reference
114
+
115
+ The default invocation name is **"mill"** (pt-BR: **"fresa"**) — change the
116
+ `invocationName` in the model JSON to whatever you prefer before pasting it. Every command
117
+ works one-shot ("Alexa, ask my mill x plus 20") or inside CNC mode ("Alexa, open
118
+ c. n. c.", then bare commands). While CNC mode is open, unrelated phrases get
119
+ a spoken reminder that Alexa is in CNC mode and how to leave; Alexa closes an
120
+ idle session on its own after a few seconds of silence (platform limit — a
121
+ custom skill cannot hold the microphone open indefinitely).
122
+
123
+ | Intent | en-US | pt-BR |
124
+ |---|---|---|
125
+ | Move (relative) | "x plus 20", "move y minus 5" | "xis mais vinte", "mover y menos cinco" |
126
+ | Go to (absolute, 1-3 axes) | "position x 130 y 100 z 5", "go to x 50" | "posição x 130 y 100 z 5", "vai para x 50" |
127
+ | Cancel jog | "halt", "stop moving" | "para", "pare" |
128
+ | Position | "where are you", "position" | "onde está", "posição" |
129
+ | Help | "help" | "ajuda" |
130
+ | Exit CNC mode | "close", "exit cnc mode" | "fechar", "sair do modo cnc" |
131
+
132
+ Absolute moves use work coordinates and the same per-axis travel limits,
133
+ measured from the current position.
134
+
135
+ ## Development
136
+
137
+ ```
138
+ npm test # unit tests (jog math, state gating, slot parsing)
139
+ node bin/cncjs-pendant-alexa --port /dev/ttyUSB0 --no-verify # local testing
140
+ ```
141
+
142
+ `--no-verify` disables Alexa signature verification so you can `curl` synthetic
143
+ request envelopes at the endpoint. Never expose an unverified endpoint.
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require('../src/cli').main(process.argv);
@@ -0,0 +1,15 @@
1
+ [Unit]
2
+ Description=HTTPS tunnel for the Alexa pendant endpoint (ngrok free static domain)
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ # One free static domain per ngrok account: https://dashboard.ngrok.com/domains
8
+ # First run once: ngrok config add-authtoken <token>
9
+ User=cncjs
10
+ ExecStart=/usr/local/bin/ngrok http 8977 --url=REPLACE-ME.ngrok-free.app
11
+ Restart=always
12
+ RestartSec=5
13
+
14
+ [Install]
15
+ WantedBy=multi-user.target
@@ -0,0 +1,20 @@
1
+ [Unit]
2
+ Description=cncjs Alexa voice pendant
3
+ After=network-online.target cncjs.service
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ # Must run as a user that can read the cncjs server's ~/.cncrc (the "secret"),
8
+ # or pass --secret-file pointing at it.
9
+ User=cncjs
10
+ ExecStart=/usr/bin/env cncjs-pendant-alexa \
11
+ --port /dev/ttyUSB0 \
12
+ --baudrate 115200 \
13
+ --cncjs-url ws://127.0.0.1:8000 \
14
+ --http-port 8977 \
15
+ --skill-id amzn1.ask.skill.REPLACE-WITH-YOUR-SKILL-ID
16
+ Restart=always
17
+ RestartSec=5
18
+
19
+ [Install]
20
+ WantedBy=multi-user.target
@@ -0,0 +1,254 @@
1
+ {
2
+ "interactionModel": {
3
+ "languageModel": {
4
+ "invocationName": "mill",
5
+ "intents": [
6
+ {
7
+ "name": "AMAZON.CancelIntent",
8
+ "samples": []
9
+ },
10
+ {
11
+ "name": "AMAZON.HelpIntent",
12
+ "samples": []
13
+ },
14
+ {
15
+ "name": "AMAZON.StopIntent",
16
+ "samples": []
17
+ },
18
+ {
19
+ "name": "AMAZON.NavigateHomeIntent",
20
+ "samples": []
21
+ },
22
+ {
23
+ "name": "AMAZON.FallbackIntent",
24
+ "samples": []
25
+ },
26
+ {
27
+ "name": "MoveIntent",
28
+ "slots": [
29
+ {
30
+ "name": "axis",
31
+ "type": "AXIS_TYPE"
32
+ },
33
+ {
34
+ "name": "direction",
35
+ "type": "DIRECTION_TYPE"
36
+ },
37
+ {
38
+ "name": "distance",
39
+ "type": "AMAZON.NUMBER"
40
+ }
41
+ ],
42
+ "samples": [
43
+ "{axis} {direction} {distance}",
44
+ "{axis} {direction} {distance} millimeters",
45
+ "{axis} {direction} {distance} millimeter",
46
+ "move {axis} {direction} {distance}",
47
+ "move the {axis} axis {direction} {distance}",
48
+ "jog {axis} {direction} {distance}",
49
+ "axis {axis} {direction} {distance}"
50
+ ]
51
+ },
52
+ {
53
+ "name": "StopJogIntent",
54
+ "samples": [
55
+ "halt",
56
+ "stop moving",
57
+ "stop the move",
58
+ "cancel the move",
59
+ "cancel the jog"
60
+ ]
61
+ },
62
+ {
63
+ "name": "StatusIntent",
64
+ "samples": [
65
+ "where are you",
66
+ "where is the spindle",
67
+ "position",
68
+ "what is the position",
69
+ "status"
70
+ ]
71
+ },
72
+ {
73
+ "name": "CloseIntent",
74
+ "samples": [
75
+ "close",
76
+ "close cnc",
77
+ "exit",
78
+ "exit cnc mode",
79
+ "leave cnc mode"
80
+ ]
81
+ },
82
+ {
83
+ "name": "GotoIntent",
84
+ "slots": [
85
+ {
86
+ "name": "xVal",
87
+ "type": "AMAZON.NUMBER"
88
+ },
89
+ {
90
+ "name": "yVal",
91
+ "type": "AMAZON.NUMBER"
92
+ },
93
+ {
94
+ "name": "zVal",
95
+ "type": "AMAZON.NUMBER"
96
+ }
97
+ ],
98
+ "samples": [
99
+ "position x {xVal}",
100
+ "position y {yVal}",
101
+ "position z {zVal}",
102
+ "position x {xVal} y {yVal}",
103
+ "position x {xVal} z {zVal}",
104
+ "position y {yVal} z {zVal}",
105
+ "position x {xVal} y {yVal} z {zVal}",
106
+ "go to x {xVal}",
107
+ "go to x {xVal} y {yVal}",
108
+ "go to x {xVal} y {yVal} z {zVal}",
109
+ "go to position x {xVal} y {yVal} z {zVal}",
110
+ "go to z {zVal}"
111
+ ]
112
+ },
113
+ {
114
+ "name": "OpenIntent",
115
+ "samples": [
116
+ "open",
117
+ "start",
118
+ "enter cnc mode",
119
+ "cnc mode"
120
+ ]
121
+ },
122
+ {
123
+ "name": "UpDownIntent",
124
+ "slots": [
125
+ {
126
+ "name": "updown",
127
+ "type": "UPDOWN_TYPE"
128
+ },
129
+ {
130
+ "name": "distance",
131
+ "type": "AMAZON.NUMBER"
132
+ }
133
+ ],
134
+ "samples": [
135
+ "{updown} {distance}",
136
+ "{updown} {distance} millimeters"
137
+ ]
138
+ },
139
+ {
140
+ "name": "ZMoveIntent",
141
+ "slots": [
142
+ {
143
+ "name": "direction",
144
+ "type": "DIRECTION_TYPE"
145
+ },
146
+ {
147
+ "name": "distance",
148
+ "type": "AMAZON.NUMBER"
149
+ }
150
+ ],
151
+ "samples": [
152
+ "z {direction} {distance}",
153
+ "zed {direction} {distance}",
154
+ "zee {direction} {distance}",
155
+ "z axis {direction} {distance}",
156
+ "z {direction} {distance} millimeters"
157
+ ]
158
+ }
159
+ ],
160
+ "types": [
161
+ {
162
+ "name": "AXIS_TYPE",
163
+ "values": [
164
+ {
165
+ "id": "X",
166
+ "name": {
167
+ "value": "x",
168
+ "synonyms": [
169
+ "ex"
170
+ ]
171
+ }
172
+ },
173
+ {
174
+ "id": "Y",
175
+ "name": {
176
+ "value": "y",
177
+ "synonyms": [
178
+ "why"
179
+ ]
180
+ }
181
+ },
182
+ {
183
+ "id": "Z",
184
+ "name": {
185
+ "value": "z",
186
+ "synonyms": [
187
+ "zee",
188
+ "zed"
189
+ ]
190
+ }
191
+ }
192
+ ]
193
+ },
194
+ {
195
+ "name": "DIRECTION_TYPE",
196
+ "values": [
197
+ {
198
+ "id": "PLUS",
199
+ "name": {
200
+ "value": "plus",
201
+ "synonyms": [
202
+ "positive",
203
+ "forward"
204
+ ]
205
+ }
206
+ },
207
+ {
208
+ "id": "MINUS",
209
+ "name": {
210
+ "value": "minus",
211
+ "synonyms": [
212
+ "negative",
213
+ "back"
214
+ ]
215
+ }
216
+ }
217
+ ]
218
+ },
219
+ {
220
+ "name": "UPDOWN_TYPE",
221
+ "values": [
222
+ {
223
+ "id": "UP",
224
+ "name": {
225
+ "value": "up",
226
+ "synonyms": [
227
+ "raise",
228
+ "go up",
229
+ "move up"
230
+ ]
231
+ }
232
+ },
233
+ {
234
+ "id": "DOWN",
235
+ "name": {
236
+ "value": "down",
237
+ "synonyms": [
238
+ "lower",
239
+ "go down",
240
+ "move down"
241
+ ]
242
+ }
243
+ }
244
+ ]
245
+ }
246
+ ],
247
+ "modelConfiguration": {
248
+ "fallbackIntentSensitivity": {
249
+ "level": "HIGH"
250
+ }
251
+ }
252
+ }
253
+ }
254
+ }