cncjs-pendant-alexa 0.1.0 → 0.2.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/README.md +1 -3
- package/assets/icon-108.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/icon.svg +15 -0
- package/cloud/.aws-sam/build/SkillFunction/index.js +152 -0
- package/cloud/.aws-sam/build/SkillFunction/lib/intent.js +74 -0
- package/cloud/.aws-sam/build/SkillFunction/lib/jog.js +105 -0
- package/cloud/.aws-sam/build/SkillFunction/lib/speech.js +64 -0
- package/cloud/.aws-sam/build/SkillFunction/lib/strings.js +82 -0
- package/cloud/.aws-sam/build/SkillFunction/machine.js +164 -0
- package/cloud/.aws-sam/build/SkillFunction/package.json +10 -0
- package/cloud/.aws-sam/build/WsFunction/index.js +120 -0
- package/cloud/.aws-sam/build/WsFunction/package.json +6 -0
- package/cloud/.aws-sam/build/template.yaml +282 -0
- package/cloud/.aws-sam/build.toml +21 -0
- package/cloud/README.md +44 -0
- package/cloud/build.sh +9 -0
- package/cloud/deploy.sh +28 -0
- package/cloud/models/en-US.json +279 -0
- package/cloud/models/pt-BR.json +302 -0
- package/cloud/skill-handler/index.js +152 -0
- package/cloud/skill-handler/machine.js +164 -0
- package/cloud/skill-handler/package.json +10 -0
- package/cloud/template.yaml +204 -0
- package/cloud/ws-handler/index.js +120 -0
- package/cloud/ws-handler/package.json +6 -0
- package/docs/privacy.md +27 -0
- package/docs/terms.md +20 -0
- package/package.json +4 -3
- package/src/actions.js +72 -0
- package/src/cli.js +28 -7
- package/src/cloud.js +146 -0
- package/src/server.js +26 -16
- package/src/skill.js +36 -129
- package/src/speech.js +64 -0
- package/src/strings.js +10 -2
- package/tests/actions.test.js +74 -0
- package/tests/cloud.test.js +107 -0
package/README.md
CHANGED
|
@@ -37,11 +37,9 @@ Voice is a lossy input, so the pendant refuses instead of trusting:
|
|
|
37
37
|
## Install
|
|
38
38
|
|
|
39
39
|
```
|
|
40
|
-
npm install -g
|
|
40
|
+
npm install -g cncjs-pendant-alexa
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
-
(Installs straight from GitHub; an npm registry release will follow.)
|
|
44
|
-
|
|
45
43
|
Run it on the same host as the cncjs server (it reads the `secret` from the
|
|
46
44
|
server's `~/.cncrc` to authenticate, exactly like the other community
|
|
47
45
|
pendants):
|
|
Binary file
|
|
Binary file
|
package/assets/icon.svg
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
|
2
|
+
<rect width="512" height="512" rx="96" fill="#0f172a"/>
|
|
3
|
+
<!-- XY axes -->
|
|
4
|
+
<line x1="96" y1="288" x2="336" y2="288" stroke="#38bdf8" stroke-width="18" stroke-linecap="round"/>
|
|
5
|
+
<line x1="216" y1="168" x2="216" y2="408" stroke="#38bdf8" stroke-width="18" stroke-linecap="round"/>
|
|
6
|
+
<polygon points="336,288 300,268 300,308" fill="#38bdf8"/>
|
|
7
|
+
<polygon points="216,168 196,204 236,204" fill="#38bdf8"/>
|
|
8
|
+
<!-- spindle (top view) -->
|
|
9
|
+
<circle cx="216" cy="288" r="52" fill="none" stroke="#e2e8f0" stroke-width="16"/>
|
|
10
|
+
<circle cx="216" cy="288" r="14" fill="#f59e0b"/>
|
|
11
|
+
<!-- voice arcs -->
|
|
12
|
+
<path d="M 356 208 A 84 84 0 0 1 356 368" fill="none" stroke="#f59e0b" stroke-width="20" stroke-linecap="round"/>
|
|
13
|
+
<path d="M 396 168 A 140 140 0 0 1 396 408" fill="none" stroke="#f59e0b" stroke-width="20" stroke-linecap="round" opacity="0.6"/>
|
|
14
|
+
<path d="M 436 128 A 196 196 0 0 1 436 448" fill="none" stroke="#f59e0b" stroke-width="20" stroke-linecap="round" opacity="0.35"/>
|
|
15
|
+
</svg>
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Alexa endpoint for the published "FluidNC CNCJS Control" skill.
|
|
2
|
+
// Mirrors the local pendant skill (same intents, same speech via the shared
|
|
3
|
+
// lib/ modules copied by cloud/build.sh) but transports commands through the
|
|
4
|
+
// relay to whichever pendant the Alexa account paired with. Adds
|
|
5
|
+
// PairIntent/UnpairIntent for the pairing-code flow.
|
|
6
|
+
|
|
7
|
+
const Alexa = require('ask-sdk-core');
|
|
8
|
+
const { parseMove, parseGoto, slotId } = require('./lib/intent');
|
|
9
|
+
const { forLocale } = require('./lib/strings');
|
|
10
|
+
const speech = require('./lib/speech');
|
|
11
|
+
const machine = require('./machine');
|
|
12
|
+
|
|
13
|
+
function t(handlerInput) {
|
|
14
|
+
return forLocale(Alexa.getLocale(handlerInput.requestEnvelope));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isOneShot(handlerInput) {
|
|
18
|
+
const env = handlerInput.requestEnvelope;
|
|
19
|
+
return Alexa.getRequestType(env) === 'IntentRequest'
|
|
20
|
+
&& !!(env.session && env.session.new)
|
|
21
|
+
&& Alexa.getIntentName(env) !== 'OpenIntent';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function speak(handlerInput, text, { end = false } = {}) {
|
|
25
|
+
const builder = handlerInput.responseBuilder.speak(text);
|
|
26
|
+
if (end || isOneShot(handlerInput)) {
|
|
27
|
+
return builder.withShouldEndSession(true).getResponse();
|
|
28
|
+
}
|
|
29
|
+
return builder.reprompt(t(handlerInput).reprompt).getResponse();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function intentHandler(name, handle) {
|
|
33
|
+
return {
|
|
34
|
+
canHandle: (h) => Alexa.getRequestType(h.requestEnvelope) === 'IntentRequest'
|
|
35
|
+
&& Alexa.getIntentName(h.requestEnvelope) === name,
|
|
36
|
+
handle
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function act(h, action, params) {
|
|
41
|
+
const s = t(h);
|
|
42
|
+
const userId = Alexa.getUserId(h.requestEnvelope);
|
|
43
|
+
const pairing = await machine.getPairing(userId);
|
|
44
|
+
if (!pairing) {
|
|
45
|
+
return speak(h, s.needsPairing);
|
|
46
|
+
}
|
|
47
|
+
const result = await machine.run(pairing, action, params);
|
|
48
|
+
return speak(h, speech.forAction(action, result, s));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const LaunchRequestHandler = {
|
|
52
|
+
canHandle: (h) => Alexa.getRequestType(h.requestEnvelope) === 'LaunchRequest',
|
|
53
|
+
handle: async (h) => {
|
|
54
|
+
const pairing = await machine.getPairing(Alexa.getUserId(h.requestEnvelope));
|
|
55
|
+
return speak(h, pairing ? t(h).launched : t(h).needsPairing);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const MoveIntentHandler = intentHandler('MoveIntent',
|
|
60
|
+
(h) => act(h, 'move', parseMove(h.requestEnvelope.request.intent)));
|
|
61
|
+
|
|
62
|
+
const UpDownIntentHandler = intentHandler('UpDownIntent', (h) => {
|
|
63
|
+
const slots = (h.requestEnvelope.request.intent || {}).slots || {};
|
|
64
|
+
const dir = slotId(slots.updown);
|
|
65
|
+
const sign = dir === 'UP' ? 1 : (dir === 'DOWN' ? -1 : null);
|
|
66
|
+
const distance = slots.distance && slots.distance.value ? Number(slots.distance.value) : NaN;
|
|
67
|
+
return act(h, 'move', { axis: 'Z', sign, distance });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const ZMoveIntentHandler = intentHandler('ZMoveIntent',
|
|
71
|
+
(h) => act(h, 'move', { ...parseMove(h.requestEnvelope.request.intent), axis: 'Z' }));
|
|
72
|
+
|
|
73
|
+
const GotoIntentHandler = intentHandler('GotoIntent',
|
|
74
|
+
(h) => act(h, 'goto', { targets: parseGoto(h.requestEnvelope.request.intent) }));
|
|
75
|
+
|
|
76
|
+
const OpenIntentHandler = intentHandler('OpenIntent', async (h) => {
|
|
77
|
+
const pairing = await machine.getPairing(Alexa.getUserId(h.requestEnvelope));
|
|
78
|
+
return speak(h, pairing ? t(h).launched : t(h).needsPairing);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const StopJogIntentHandler = intentHandler('StopJogIntent', (h) => act(h, 'stop'));
|
|
82
|
+
|
|
83
|
+
const StatusIntentHandler = intentHandler('StatusIntent', (h) => act(h, 'status'));
|
|
84
|
+
|
|
85
|
+
const PairIntentHandler = intentHandler('PairIntent', async (h) => {
|
|
86
|
+
const s = t(h);
|
|
87
|
+
const slots = (h.requestEnvelope.request.intent || {}).slots || {};
|
|
88
|
+
const raw = slots.code && slots.code.value ? String(slots.code.value) : '';
|
|
89
|
+
const code = raw.replace(/\D/g, '').padStart(6, '0');
|
|
90
|
+
if (!/^\d{6}$/.test(code)) {
|
|
91
|
+
return speak(h, s.pairBadCode);
|
|
92
|
+
}
|
|
93
|
+
const result = await machine.pair(Alexa.getUserId(h.requestEnvelope), code);
|
|
94
|
+
return speak(h, result.ok ? s.pairOk : s.pairBadCode);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const UnpairIntentHandler = intentHandler('UnpairIntent', async (h) => {
|
|
98
|
+
await machine.unpair(Alexa.getUserId(h.requestEnvelope));
|
|
99
|
+
return speak(h, t(h).unpairOk, { end: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const HelpIntentHandler = intentHandler('AMAZON.HelpIntent', (h) => speak(h, t(h).help));
|
|
103
|
+
|
|
104
|
+
const ExitHandler = {
|
|
105
|
+
canHandle: (h) => Alexa.getRequestType(h.requestEnvelope) === 'IntentRequest'
|
|
106
|
+
&& ['CloseIntent', 'AMAZON.StopIntent', 'AMAZON.CancelIntent', 'AMAZON.NavigateHomeIntent']
|
|
107
|
+
.includes(Alexa.getIntentName(h.requestEnvelope)),
|
|
108
|
+
handle: async (h) => {
|
|
109
|
+
const userId = Alexa.getUserId(h.requestEnvelope);
|
|
110
|
+
const pairing = await machine.getPairing(userId);
|
|
111
|
+
if (pairing) {
|
|
112
|
+
await machine.run(pairing, 'stop', {});
|
|
113
|
+
}
|
|
114
|
+
return speak(h, t(h).bye, { end: true });
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const FallbackIntentHandler = intentHandler('AMAZON.FallbackIntent',
|
|
119
|
+
(h) => speak(h, isOneShot(h) ? t(h).fallbackOneShot : t(h).fallback));
|
|
120
|
+
|
|
121
|
+
const SessionEndedRequestHandler = {
|
|
122
|
+
canHandle: (h) => Alexa.getRequestType(h.requestEnvelope) === 'SessionEndedRequest',
|
|
123
|
+
handle: (h) => h.responseBuilder.getResponse()
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const ErrorHandler = {
|
|
127
|
+
canHandle: () => true,
|
|
128
|
+
handle: (h, error) => {
|
|
129
|
+
console.error('skill error:', error);
|
|
130
|
+
return speak(h, t(h).fallback);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
exports.handler = Alexa.SkillBuilders.custom()
|
|
135
|
+
.addRequestHandlers(
|
|
136
|
+
LaunchRequestHandler,
|
|
137
|
+
MoveIntentHandler,
|
|
138
|
+
UpDownIntentHandler,
|
|
139
|
+
ZMoveIntentHandler,
|
|
140
|
+
GotoIntentHandler,
|
|
141
|
+
OpenIntentHandler,
|
|
142
|
+
StopJogIntentHandler,
|
|
143
|
+
StatusIntentHandler,
|
|
144
|
+
PairIntentHandler,
|
|
145
|
+
UnpairIntentHandler,
|
|
146
|
+
HelpIntentHandler,
|
|
147
|
+
ExitHandler,
|
|
148
|
+
FallbackIntentHandler,
|
|
149
|
+
SessionEndedRequestHandler
|
|
150
|
+
)
|
|
151
|
+
.addErrorHandlers(ErrorHandler)
|
|
152
|
+
.lambda();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Pure slot parsing for the Alexa intents. Prefers entity-resolution ids
|
|
2
|
+
// (stable across locales) and falls back to matching the raw spoken value.
|
|
3
|
+
|
|
4
|
+
const AXIS_WORDS = {
|
|
5
|
+
x: 'X', xis: 'X', ex: 'X',
|
|
6
|
+
y: 'Y', 'ípsilon': 'Y', ipsilon: 'Y', why: 'Y',
|
|
7
|
+
z: 'Z', 'zê': 'Z', ze: 'Z', zee: 'Z', zed: 'Z'
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const DIRECTION_WORDS = {
|
|
11
|
+
mais: 1, positivo: 1, plus: 1, positive: 1,
|
|
12
|
+
menos: -1, negativo: -1, minus: -1, negative: -1, less: -1
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function slotId(slot) {
|
|
16
|
+
const auths = slot && slot.resolutions && slot.resolutions.resolutionsPerAuthority;
|
|
17
|
+
if (Array.isArray(auths)) {
|
|
18
|
+
for (const auth of auths) {
|
|
19
|
+
if (auth.status && auth.status.code === 'ER_SUCCESS_MATCH'
|
|
20
|
+
&& auth.values && auth.values[0] && auth.values[0].value) {
|
|
21
|
+
return auth.values[0].value.id;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseAxis(slot) {
|
|
29
|
+
const id = slotId(slot);
|
|
30
|
+
if (id) {
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
const raw = slot && slot.value ? String(slot.value).trim().toLowerCase() : '';
|
|
34
|
+
return AXIS_WORDS[raw] || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseDirection(slot) {
|
|
38
|
+
const id = slotId(slot);
|
|
39
|
+
if (id === 'PLUS') {
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
if (id === 'MINUS') {
|
|
43
|
+
return -1;
|
|
44
|
+
}
|
|
45
|
+
const raw = slot && slot.value ? String(slot.value).trim().toLowerCase() : '';
|
|
46
|
+
return DIRECTION_WORDS[raw] || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// -> { axis: 'X'|'Y'|'Z'|null, sign: 1|-1|null, distance: number|NaN }
|
|
50
|
+
function parseMove(intent) {
|
|
51
|
+
const slots = (intent && intent.slots) || {};
|
|
52
|
+
const axis = parseAxis(slots.axis);
|
|
53
|
+
const sign = parseDirection(slots.direction);
|
|
54
|
+
const distance = (slots.distance && slots.distance.value !== undefined
|
|
55
|
+
&& slots.distance.value !== null && slots.distance.value !== '')
|
|
56
|
+
? Number(slots.distance.value)
|
|
57
|
+
: NaN;
|
|
58
|
+
return { axis, sign, distance };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// -> { X?: number, Y?: number, Z?: number } (absent = axis not spoken)
|
|
62
|
+
function parseGoto(intent) {
|
|
63
|
+
const slots = (intent && intent.slots) || {};
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const [slot, axis] of [['xVal', 'X'], ['yVal', 'Y'], ['zVal', 'Z']]) {
|
|
66
|
+
const s = slots[slot];
|
|
67
|
+
if (s && s.value !== undefined && s.value !== null && s.value !== '') {
|
|
68
|
+
out[axis] = Number(s.value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { parseMove, parseGoto, parseAxis, parseDirection, slotId };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Pure jog logic: validation, state gating and $J= command formatting.
|
|
2
|
+
// Everything here is testable without a network or a machine.
|
|
3
|
+
|
|
4
|
+
const AXES = ['X', 'Y', 'Z'];
|
|
5
|
+
|
|
6
|
+
const DEFAULT_LIMITS = {
|
|
7
|
+
maxXY: 50, // mm per single voice command
|
|
8
|
+
maxZ: 10,
|
|
9
|
+
feedXY: 1000, // mm/min
|
|
10
|
+
feedZ: 300
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Grbl-style jog is only safe when nothing else owns the machine:
|
|
14
|
+
// the sender workflow must be idle and the controller Idle (or already
|
|
15
|
+
// jogging — Grbl 1.1 reports "Jog" while a $J= move runs).
|
|
16
|
+
function canJog({ activeState, workflowState }) {
|
|
17
|
+
if (workflowState && workflowState !== 'idle') {
|
|
18
|
+
return { ok: false, reason: 'running' };
|
|
19
|
+
}
|
|
20
|
+
if (activeState === 'Idle' || activeState === 'Jog') {
|
|
21
|
+
return { ok: true };
|
|
22
|
+
}
|
|
23
|
+
if (activeState === 'Alarm') {
|
|
24
|
+
return { ok: false, reason: 'alarm' };
|
|
25
|
+
}
|
|
26
|
+
if (!activeState) {
|
|
27
|
+
return { ok: false, reason: 'disconnected' };
|
|
28
|
+
}
|
|
29
|
+
return { ok: false, reason: 'busy' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateMove({ axis, sign, distance }, limits = DEFAULT_LIMITS) {
|
|
33
|
+
const ax = String(axis || '').toUpperCase();
|
|
34
|
+
if (!AXES.includes(ax)) {
|
|
35
|
+
return { ok: false, reason: 'badAxis' };
|
|
36
|
+
}
|
|
37
|
+
if (sign !== 1 && sign !== -1) {
|
|
38
|
+
return { ok: false, reason: 'badDirection' };
|
|
39
|
+
}
|
|
40
|
+
const d = Number(distance);
|
|
41
|
+
if (!Number.isFinite(d) || d <= 0) {
|
|
42
|
+
return { ok: false, reason: 'badDistance' };
|
|
43
|
+
}
|
|
44
|
+
const max = (ax === 'Z') ? limits.maxZ : limits.maxXY;
|
|
45
|
+
if (d > max) {
|
|
46
|
+
return { ok: false, reason: 'tooFar', max };
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, axis: ax, delta: sign * d };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 20 -> "20", 20.5 -> "20.5", 0.1234 -> "0.123"
|
|
52
|
+
function formatMm(n) {
|
|
53
|
+
return String(Math.round(n * 1000) / 1000);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildJog(axis, delta, limits = DEFAULT_LIMITS) {
|
|
57
|
+
const feed = (axis === 'Z') ? limits.feedZ : limits.feedXY;
|
|
58
|
+
return `$J=G91 G21 ${axis}${formatMm(delta)} F${feed}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Absolute goto in work coordinates ("posição x 130 y 100 z 5").
|
|
62
|
+
// targets: { X?: number, Y?: number, Z?: number } — at least one axis.
|
|
63
|
+
// The same per-axis travel limits apply, measured from the CURRENT work
|
|
64
|
+
// position, so a voice mishearing can never produce a longer move than a
|
|
65
|
+
// relative jog could.
|
|
66
|
+
function validateGoto(targets, wpos, limits = DEFAULT_LIMITS) {
|
|
67
|
+
const entries = Object.entries(targets).filter(([, v]) => v !== undefined && v !== null);
|
|
68
|
+
if (entries.length === 0) {
|
|
69
|
+
return { ok: false, reason: 'missingSlots' };
|
|
70
|
+
}
|
|
71
|
+
if (!wpos) {
|
|
72
|
+
return { ok: false, reason: 'disconnected' };
|
|
73
|
+
}
|
|
74
|
+
const clean = {};
|
|
75
|
+
for (const [axis, value] of entries) {
|
|
76
|
+
const ax = String(axis).toUpperCase();
|
|
77
|
+
const target = Number(value);
|
|
78
|
+
const current = Number(wpos[ax.toLowerCase()]);
|
|
79
|
+
if (!AXES.includes(ax)) {
|
|
80
|
+
return { ok: false, reason: 'badAxis' };
|
|
81
|
+
}
|
|
82
|
+
if (!Number.isFinite(target)) {
|
|
83
|
+
return { ok: false, reason: 'badDistance' };
|
|
84
|
+
}
|
|
85
|
+
if (!Number.isFinite(current)) {
|
|
86
|
+
return { ok: false, reason: 'disconnected' };
|
|
87
|
+
}
|
|
88
|
+
const max = (ax === 'Z') ? limits.maxZ : limits.maxXY;
|
|
89
|
+
if (Math.abs(target - current) > max) {
|
|
90
|
+
return { ok: false, reason: 'tooFar', max, axis: ax };
|
|
91
|
+
}
|
|
92
|
+
clean[ax] = target;
|
|
93
|
+
}
|
|
94
|
+
return { ok: true, targets: clean };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function buildGoto(targets, limits = DEFAULT_LIMITS) {
|
|
98
|
+
const feed = (targets.Z !== undefined) ? limits.feedZ : limits.feedXY;
|
|
99
|
+
const parts = AXES.filter((a) => targets[a] !== undefined)
|
|
100
|
+
.map((a) => `${a}${formatMm(targets[a])}`)
|
|
101
|
+
.join(' ');
|
|
102
|
+
return `$J=G90 G21 ${parts} F${feed}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { AXES, DEFAULT_LIMITS, canJog, validateMove, buildJog, validateGoto, buildGoto, formatMm };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Renders action results (actions.js) into spoken text. Shared verbatim by
|
|
2
|
+
// the local skill endpoint and the cloud relay Lambda so both modes answer
|
|
3
|
+
// identically.
|
|
4
|
+
|
|
5
|
+
const { formatMm } = require('./jog');
|
|
6
|
+
const { speakNumber } = require('./strings');
|
|
7
|
+
|
|
8
|
+
function refusal(result, s) {
|
|
9
|
+
switch (result.reason) {
|
|
10
|
+
case 'running': return s.running;
|
|
11
|
+
case 'alarm': return s.alarm;
|
|
12
|
+
case 'busy': return s.busy;
|
|
13
|
+
case 'disconnected': return s.disconnected;
|
|
14
|
+
case 'tooFar': return s.tooFar(result.max);
|
|
15
|
+
case 'badDistance': return s.badDistance;
|
|
16
|
+
default: return s.missingSlots;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function moveSpeech(result, s) {
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
return refusal(result, s);
|
|
23
|
+
}
|
|
24
|
+
const dirWord = (result.delta > 0) ? s.dirPlus : s.dirMinus;
|
|
25
|
+
const dist = speakNumber(formatMm(Math.abs(result.delta)), s);
|
|
26
|
+
return s.moved(s.axisNames[result.axis], dirWord, dist);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function gotoSpeech(result, s) {
|
|
30
|
+
if (!result.ok) {
|
|
31
|
+
return refusal(result, s);
|
|
32
|
+
}
|
|
33
|
+
const parts = Object.entries(result.targets)
|
|
34
|
+
.map(([axis, v]) => `${s.axisNames[axis]} ${speakNumber(v, s)}`)
|
|
35
|
+
.join(', ');
|
|
36
|
+
return s.goingTo(parts);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function statusSpeech(result, s) {
|
|
40
|
+
if (!result.ok) {
|
|
41
|
+
return refusal(result, s);
|
|
42
|
+
}
|
|
43
|
+
return s.position(
|
|
44
|
+
speakNumber(result.wpos.x, s),
|
|
45
|
+
speakNumber(result.wpos.y, s),
|
|
46
|
+
speakNumber(result.wpos.z, s)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stopSpeech(result, s) {
|
|
51
|
+
return result.ok ? s.stopped : refusal(result, s);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function forAction(action, result, s) {
|
|
55
|
+
switch (action) {
|
|
56
|
+
case 'move': return moveSpeech(result, s);
|
|
57
|
+
case 'goto': return gotoSpeech(result, s);
|
|
58
|
+
case 'status': return statusSpeech(result, s);
|
|
59
|
+
case 'stop': return stopSpeech(result, s);
|
|
60
|
+
default: return refusal(result || { reason: 'missingSlots' }, s);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { forAction, refusal };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Spoken responses per locale. Numbers are localized (decimal comma for pt).
|
|
2
|
+
|
|
3
|
+
const LOCALES = {
|
|
4
|
+
'pt': {
|
|
5
|
+
decimalComma: true,
|
|
6
|
+
minusWord: 'menos',
|
|
7
|
+
axisNames: { X: 'xis', Y: 'ípsilon', Z: 'zê' },
|
|
8
|
+
launched: 'Modo CNC ativo. Pode mandar, por exemplo: xis mais vinte. Fale fechar para sair.',
|
|
9
|
+
reprompt: 'Modo CNC ativo. Mande o próximo movimento ou fale fechar.',
|
|
10
|
+
moved: (axis, dirWord, dist) => `Movendo ${axis} ${dirWord} ${dist} milímetros.`,
|
|
11
|
+
goingTo: (parts) => `Indo para ${parts}.`,
|
|
12
|
+
dirPlus: 'mais',
|
|
13
|
+
dirMinus: 'menos',
|
|
14
|
+
tooFar: (max) => `Isso passa do limite de ${max} milímetros por comando. Não vou mover.`,
|
|
15
|
+
running: 'A máquina está executando um trabalho. Não vou mover.',
|
|
16
|
+
alarm: 'A máquina está em alarme. Destrave pelo painel antes de mover.',
|
|
17
|
+
busy: 'A máquina está ocupada agora. Tente quando ela estiver parada.',
|
|
18
|
+
disconnected: 'Estou sem conexão com a máquina.',
|
|
19
|
+
stopped: 'Parado.',
|
|
20
|
+
position: (x, y, z) => `Posição de trabalho: xis ${x}, ípsilon ${y}, zê ${z}.`,
|
|
21
|
+
missingSlots: 'Não entendi o movimento. Diga o eixo, a direção e a distância. Por exemplo: xis mais vinte.',
|
|
22
|
+
badDistance: 'Não entendi a distância. Diga um número de milímetros maior que zero.',
|
|
23
|
+
help: 'Diga um eixo, uma direção e uma distância em milímetros. Por exemplo: xis mais vinte. Para o eixo zê, o mais fácil é: sobe dez, ou desce cinco. Diga para, para cancelar um movimento, ou posição, para saber onde o spindle está.',
|
|
24
|
+
bye: 'Saindo do modo CNC.',
|
|
25
|
+
fallback: 'Alexa está em modo CNC. Fale fechar para sair do modo CNC. Para mover, diga por exemplo: xis mais vinte.',
|
|
26
|
+
fallbackOneShot: 'Não entendi o comando. Diga por exemplo: peça para fresa xis mais vinte, ou zeta mais dez.',
|
|
27
|
+
needsPairing: 'Sua Alexa ainda não está pareada com uma máquina. Instale o pendant cncjs pendant alexa no computador do cncjs com a opção cloud, pegue o código de seis dígitos que ele mostra, e diga: parear, seguido do código.',
|
|
28
|
+
pairOk: 'Pareado com sucesso. Pode mandar, por exemplo: xis mais vinte.',
|
|
29
|
+
pairBadCode: 'Código inválido ou expirado. Confira o código de seis dígitos no pendant e diga: parear, seguido do código.',
|
|
30
|
+
unpairOk: 'Pareamento desfeito.'
|
|
31
|
+
},
|
|
32
|
+
'en': {
|
|
33
|
+
decimalComma: false,
|
|
34
|
+
minusWord: 'minus',
|
|
35
|
+
axisNames: { X: 'x', Y: 'y', Z: 'z' },
|
|
36
|
+
launched: 'CNC mode active. Try, for example: x plus twenty. Say close to exit.',
|
|
37
|
+
reprompt: 'CNC mode active. Send the next move or say close.',
|
|
38
|
+
moved: (axis, dirWord, dist) => `Moving ${axis} ${dirWord} ${dist} millimeters.`,
|
|
39
|
+
goingTo: (parts) => `Going to ${parts}.`,
|
|
40
|
+
dirPlus: 'plus',
|
|
41
|
+
dirMinus: 'minus',
|
|
42
|
+
tooFar: (max) => `That exceeds the limit of ${max} millimeters per command. Not moving.`,
|
|
43
|
+
running: 'The machine is running a job. Not moving.',
|
|
44
|
+
alarm: 'The machine is in alarm. Unlock it from the panel before moving.',
|
|
45
|
+
busy: 'The machine is busy right now. Try when it is idle.',
|
|
46
|
+
disconnected: 'I have no connection to the machine.',
|
|
47
|
+
stopped: 'Stopped.',
|
|
48
|
+
position: (x, y, z) => `Work position: x ${x}, y ${y}, z ${z}.`,
|
|
49
|
+
missingSlots: 'I did not get that move. Say the axis, the direction and the distance. For example: x plus twenty.',
|
|
50
|
+
badDistance: 'I did not get the distance. Say a number of millimeters greater than zero.',
|
|
51
|
+
help: 'Say an axis, a direction and a distance in millimeters. For example: x plus twenty, or z minus five. Say stop to cancel a move, or position to know where the spindle is.',
|
|
52
|
+
bye: 'Leaving CNC mode.',
|
|
53
|
+
fallback: 'Alexa is in CNC mode. Say close to exit CNC mode. To move, say for example: x plus twenty.',
|
|
54
|
+
fallbackOneShot: 'I did not get that command. Say for example: ask mill x plus twenty.',
|
|
55
|
+
needsPairing: 'Your Alexa is not paired with a machine yet. Install the cncjs pendant alexa on your cncjs computer with the cloud option, take the six digit code it shows, and say: pair, followed by the code.',
|
|
56
|
+
pairOk: 'Paired successfully. Try, for example: x plus twenty.',
|
|
57
|
+
pairBadCode: 'Invalid or expired code. Check the six digit code on the pendant and say: pair, followed by the code.',
|
|
58
|
+
unpairOk: 'Unpaired.'
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function forLocale(locale) {
|
|
63
|
+
const lang = String(locale || 'en').slice(0, 2).toLowerCase();
|
|
64
|
+
return LOCALES[lang] || LOCALES.en;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// "25.500" -> "25.5" / "-2.000" -> "menos 2" (spoken-friendly)
|
|
68
|
+
function speakNumber(value, t) {
|
|
69
|
+
let n = Number(value);
|
|
70
|
+
if (!Number.isFinite(n)) {
|
|
71
|
+
return String(value);
|
|
72
|
+
}
|
|
73
|
+
n = Math.round(n * 1000) / 1000;
|
|
74
|
+
const negative = n < 0;
|
|
75
|
+
let s = String(Math.abs(n));
|
|
76
|
+
if (t.decimalComma) {
|
|
77
|
+
s = s.replace('.', ',');
|
|
78
|
+
}
|
|
79
|
+
return negative ? `${t.minusWord} ${s}` : s;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { forLocale, speakNumber };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Pairing resolution and command transport for the skill Lambda.
|
|
2
|
+
// Two machine kinds: a real pendant (command over WebSocket, correlated
|
|
3
|
+
// response polled from DynamoDB) and the DEMO machine (simulated in the
|
|
4
|
+
// pairing item) used by Amazon certification testers and curious users.
|
|
5
|
+
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
|
|
8
|
+
const { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand, DeleteCommand } = require('@aws-sdk/lib-dynamodb');
|
|
9
|
+
const { ApiGatewayManagementApiClient, PostToConnectionCommand } = require('@aws-sdk/client-apigatewaymanagementapi');
|
|
10
|
+
const { validateMove, validateGoto, DEFAULT_LIMITS } = require('./lib/jog');
|
|
11
|
+
|
|
12
|
+
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
|
|
13
|
+
const mgmt = new ApiGatewayManagementApiClient({ endpoint: process.env.WS_ENDPOINT });
|
|
14
|
+
|
|
15
|
+
const PENDANTS = process.env.PENDANTS_TABLE;
|
|
16
|
+
const PAIRINGS = process.env.PAIRINGS_TABLE;
|
|
17
|
+
const CODES = process.env.CODES_TABLE;
|
|
18
|
+
const RESPONSES = process.env.RESPONSES_TABLE;
|
|
19
|
+
|
|
20
|
+
const DEMO_CODE = '123456';
|
|
21
|
+
const DEMO_PENDANT = 'DEMO';
|
|
22
|
+
const RESPONSE_TIMEOUT_MS = 6000;
|
|
23
|
+
const POLL_MS = 150;
|
|
24
|
+
|
|
25
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
const fmt = (n) => (Math.round(n * 1000) / 1000).toFixed(3);
|
|
27
|
+
|
|
28
|
+
async function getPairing(userId) {
|
|
29
|
+
const item = (await ddb.send(new GetCommand({
|
|
30
|
+
TableName: PAIRINGS, Key: { userId }
|
|
31
|
+
}))).Item;
|
|
32
|
+
return item || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function pair(userId, code) {
|
|
36
|
+
let pendantId;
|
|
37
|
+
if (code === DEMO_CODE) {
|
|
38
|
+
pendantId = DEMO_PENDANT;
|
|
39
|
+
} else {
|
|
40
|
+
const item = (await ddb.send(new GetCommand({
|
|
41
|
+
TableName: CODES, Key: { code }
|
|
42
|
+
}))).Item;
|
|
43
|
+
if (!item || item.ttl < Math.floor(Date.now() / 1000)) {
|
|
44
|
+
return { ok: false };
|
|
45
|
+
}
|
|
46
|
+
pendantId = item.pendantId;
|
|
47
|
+
await ddb.send(new DeleteCommand({ TableName: CODES, Key: { code } }));
|
|
48
|
+
}
|
|
49
|
+
await ddb.send(new PutCommand({
|
|
50
|
+
TableName: PAIRINGS,
|
|
51
|
+
Item: { userId, pendantId, demo: { x: 0, y: 0, z: 0 }, pairedAt: Date.now() }
|
|
52
|
+
}));
|
|
53
|
+
if (pendantId !== DEMO_PENDANT) {
|
|
54
|
+
await ddb.send(new UpdateCommand({
|
|
55
|
+
TableName: PENDANTS,
|
|
56
|
+
Key: { pendantId },
|
|
57
|
+
UpdateExpression: 'SET pairedUserId = :u',
|
|
58
|
+
ExpressionAttributeValues: { ':u': userId }
|
|
59
|
+
}));
|
|
60
|
+
const pendant = (await ddb.send(new GetCommand({
|
|
61
|
+
TableName: PENDANTS, Key: { pendantId }
|
|
62
|
+
}))).Item;
|
|
63
|
+
if (pendant && pendant.connectionId) {
|
|
64
|
+
try {
|
|
65
|
+
await mgmt.send(new PostToConnectionCommand({
|
|
66
|
+
ConnectionId: pendant.connectionId,
|
|
67
|
+
Data: JSON.stringify({ type: 'paired' })
|
|
68
|
+
}));
|
|
69
|
+
} catch (err) { /* pendant offline; it learns on next register */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function unpair(userId) {
|
|
76
|
+
const pairing = await getPairing(userId);
|
|
77
|
+
if (pairing && pairing.pendantId !== DEMO_PENDANT) {
|
|
78
|
+
await ddb.send(new UpdateCommand({
|
|
79
|
+
TableName: PENDANTS,
|
|
80
|
+
Key: { pendantId: pairing.pendantId },
|
|
81
|
+
UpdateExpression: 'SET pairedUserId = :n',
|
|
82
|
+
ExpressionAttributeValues: { ':n': null }
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
await ddb.send(new DeleteCommand({ TableName: PAIRINGS, Key: { userId } }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Demo machine: same validation rules as a real pendant, position kept in
|
|
89
|
+
// the pairing item so reviewers get coherent status readouts.
|
|
90
|
+
async function runDemo(pairing, action, params) {
|
|
91
|
+
const pos = pairing.demo || { x: 0, y: 0, z: 0 };
|
|
92
|
+
const wpos = { x: fmt(pos.x), y: fmt(pos.y), z: fmt(pos.z) };
|
|
93
|
+
if (action === 'status') {
|
|
94
|
+
return { ok: true, wpos, activeState: 'Idle' };
|
|
95
|
+
}
|
|
96
|
+
if (action === 'stop') {
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
let next = { ...pos };
|
|
100
|
+
let result;
|
|
101
|
+
if (action === 'move') {
|
|
102
|
+
const valid = validateMove(params, DEFAULT_LIMITS);
|
|
103
|
+
if (!valid.ok) {
|
|
104
|
+
return valid;
|
|
105
|
+
}
|
|
106
|
+
next[valid.axis.toLowerCase()] += valid.delta;
|
|
107
|
+
result = { ok: true, axis: valid.axis, delta: valid.delta };
|
|
108
|
+
} else if (action === 'goto') {
|
|
109
|
+
const valid = validateGoto(params.targets || params, wpos, DEFAULT_LIMITS);
|
|
110
|
+
if (!valid.ok) {
|
|
111
|
+
return valid;
|
|
112
|
+
}
|
|
113
|
+
for (const [axis, value] of Object.entries(valid.targets)) {
|
|
114
|
+
next[axis.toLowerCase()] = value;
|
|
115
|
+
}
|
|
116
|
+
result = { ok: true, targets: valid.targets };
|
|
117
|
+
} else {
|
|
118
|
+
return { ok: false, reason: 'badAction' };
|
|
119
|
+
}
|
|
120
|
+
await ddb.send(new UpdateCommand({
|
|
121
|
+
TableName: PAIRINGS,
|
|
122
|
+
Key: { userId: pairing.userId },
|
|
123
|
+
UpdateExpression: 'SET demo = :d',
|
|
124
|
+
ExpressionAttributeValues: { ':d': next }
|
|
125
|
+
}));
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function runRemote(pairing, action, params) {
|
|
130
|
+
const pendant = (await ddb.send(new GetCommand({
|
|
131
|
+
TableName: PENDANTS, Key: { pendantId: pairing.pendantId }
|
|
132
|
+
}))).Item;
|
|
133
|
+
if (!pendant || !pendant.connectionId) {
|
|
134
|
+
return { ok: false, reason: 'disconnected' };
|
|
135
|
+
}
|
|
136
|
+
const id = crypto.randomUUID();
|
|
137
|
+
try {
|
|
138
|
+
await mgmt.send(new PostToConnectionCommand({
|
|
139
|
+
ConnectionId: pendant.connectionId,
|
|
140
|
+
Data: JSON.stringify({ type: 'cmd', id, action, params })
|
|
141
|
+
}));
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return { ok: false, reason: 'disconnected' };
|
|
144
|
+
}
|
|
145
|
+
const deadline = Date.now() + RESPONSE_TIMEOUT_MS;
|
|
146
|
+
while (Date.now() < deadline) {
|
|
147
|
+
await sleep(POLL_MS);
|
|
148
|
+
const item = (await ddb.send(new GetCommand({
|
|
149
|
+
TableName: RESPONSES, Key: { id }
|
|
150
|
+
}))).Item;
|
|
151
|
+
if (item) {
|
|
152
|
+
return item.result;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { ok: false, reason: 'disconnected' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function run(pairing, action, params) {
|
|
159
|
+
return (pairing.pendantId === DEMO_PENDANT)
|
|
160
|
+
? runDemo(pairing, action, params)
|
|
161
|
+
: runRemote(pairing, action, params);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = { getPairing, pair, unpair, run, DEMO_CODE };
|