@folotoy/folotoy-openclaw-plugin 0.1.0 → 0.1.2
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 +45 -25
- package/openclaw.plugin.json +50 -3
- package/package.json +3 -3
- package/src/__tests__/channel.test.ts +10 -10
- package/src/__tests__/mqtt.test.ts +10 -4
- package/src/config.ts +27 -0
- package/src/index.ts +49 -73
- package/src/mqtt.ts +6 -2
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# @folotoy/folotoy-openclaw-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Empower your FoloToy with OpenClaw AI capabilities.
|
|
4
|
+
|
|
5
|
+
An [OpenClaw](https://openclaw.ai) channel plugin that bridges FoloToy smart toys with OpenClaw via MQTT.
|
|
4
6
|
|
|
5
7
|
```
|
|
6
|
-
FoloToy
|
|
8
|
+
FoloToy Toy <──MQTT──> FoloToy MQTT Broker <──MQTT──> Plugin <──> OpenClaw
|
|
7
9
|
```
|
|
8
10
|
|
|
9
11
|
## Installation
|
|
@@ -12,7 +14,7 @@ FoloToy 玩具 <──MQTT──> FoloToy MQTT Broker <──MQTT──> Thi
|
|
|
12
14
|
openclaw plugins install @folotoy/folotoy-openclaw-plugin
|
|
13
15
|
```
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
For local development:
|
|
16
18
|
|
|
17
19
|
```bash
|
|
18
20
|
openclaw plugins install -l .
|
|
@@ -20,19 +22,17 @@ openclaw plugins install -l .
|
|
|
20
22
|
|
|
21
23
|
## Configuration
|
|
22
24
|
|
|
23
|
-
The plugin supports two authentication flows.
|
|
25
|
+
The plugin supports two authentication flows. All fields are configured as flat key-value pairs in `openclaw.json` under `channels.folotoy`.
|
|
24
26
|
|
|
25
27
|
### Flow 2: Direct SN + Key (Default)
|
|
26
28
|
|
|
27
|
-
Configure your toy SN and key directly:
|
|
28
|
-
|
|
29
29
|
| Field | Description |
|
|
30
30
|
|-------|-------------|
|
|
31
|
-
| `
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
31
|
+
| `flow` | `"direct"` |
|
|
32
|
+
| `toy_sn` | Toy serial number |
|
|
33
|
+
| `toy_key` | Toy key (used as MQTT password) |
|
|
34
|
+
| `mqtt_host` | MQTT broker host (default: `198.19.249.25`) |
|
|
35
|
+
| `mqtt_port` | MQTT broker port (default: `1883`) |
|
|
36
36
|
|
|
37
37
|
### Flow 1: HTTP API Login
|
|
38
38
|
|
|
@@ -40,22 +40,38 @@ Exchange an API key for MQTT credentials via the FoloToy API:
|
|
|
40
40
|
|
|
41
41
|
| Field | Description |
|
|
42
42
|
|-------|-------------|
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
43
|
+
| `flow` | `"api"` |
|
|
44
|
+
| `toy_sn` | Toy serial number |
|
|
45
|
+
| `api_url` | FoloToy API base URL (default: `https://api.folotoy.cn`) |
|
|
46
|
+
| `api_key` | Bearer token |
|
|
47
|
+
| `mqtt_host` | MQTT broker host |
|
|
48
|
+
| `mqtt_port` | MQTT broker port (default: `1883`) |
|
|
49
|
+
|
|
50
|
+
Example `openclaw.json`:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"channels": {
|
|
55
|
+
"folotoy": {
|
|
56
|
+
"flow": "direct",
|
|
57
|
+
"toy_sn": "your-toy-sn",
|
|
58
|
+
"toy_key": "your-toy-key",
|
|
59
|
+
"mqtt_host": "198.19.249.25"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
49
64
|
|
|
50
65
|
## MQTT
|
|
51
66
|
|
|
52
|
-
|
|
67
|
+
Inbound and outbound use separate topics:
|
|
53
68
|
|
|
54
69
|
```
|
|
55
|
-
/openapi/folotoy/{sn}/thing/
|
|
70
|
+
Inbound (Toy → Plugin): /openapi/folotoy/{sn}/thing/command/call
|
|
71
|
+
Outbound (Plugin → Toy): /openapi/folotoy/{sn}/thing/command/callAck
|
|
56
72
|
```
|
|
57
73
|
|
|
58
|
-
The plugin connects
|
|
74
|
+
The plugin connects with an `openapi:` prefix on the username to distinguish itself from the toy's own connection:
|
|
59
75
|
|
|
60
76
|
```
|
|
61
77
|
username: openapi:{toy_sn}
|
|
@@ -78,8 +94,6 @@ password: {toy_key}
|
|
|
78
94
|
|
|
79
95
|
**Plugin → Toy (outbound)**
|
|
80
96
|
|
|
81
|
-
Single response message with `msgId` matching the inbound request:
|
|
82
|
-
|
|
83
97
|
```json
|
|
84
98
|
{
|
|
85
99
|
"msgId": 1,
|
|
@@ -90,20 +104,26 @@ Single response message with `msgId` matching the inbound request:
|
|
|
90
104
|
}
|
|
91
105
|
```
|
|
92
106
|
|
|
107
|
+
`msgId` starts at 1 per session and auto-increments.
|
|
108
|
+
|
|
93
109
|
## Environments
|
|
94
110
|
|
|
95
111
|
| Environment | MQTT Host | Port |
|
|
96
112
|
|-------------|-----------|------|
|
|
97
|
-
| Development | `
|
|
113
|
+
| Development | `198.19.249.25` | 1883 |
|
|
98
114
|
| Testing | `f.qrc92.cn` | 1883 |
|
|
99
115
|
| Production | `f.folotoy.cn` | 1883 |
|
|
100
116
|
|
|
101
|
-
Switch environments via the `FOLOTOY_MQTT_HOST` environment variable.
|
|
117
|
+
Switch environments via the `FOLOTOY_MQTT_HOST` environment variable or `mqtt_host` config field.
|
|
102
118
|
|
|
103
119
|
## Development
|
|
104
120
|
|
|
105
121
|
```bash
|
|
106
122
|
npm install
|
|
107
|
-
npm run build
|
|
108
123
|
npm test
|
|
124
|
+
npm run build
|
|
109
125
|
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "folotoy",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
2
|
+
"id": "folotoy-openclaw-plugin",
|
|
3
|
+
"name": "FoloToy",
|
|
4
|
+
"description": "Empower your FoloToy with OpenClaw AI capabilities.",
|
|
5
|
+
"version": "0.1.2",
|
|
6
|
+
"channels": ["folotoy"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"flow": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"enum": ["direct", "api"],
|
|
14
|
+
"description": "Authentication flow",
|
|
15
|
+
"default": "direct"
|
|
16
|
+
},
|
|
17
|
+
"toy_sn": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Toy serial number"
|
|
20
|
+
},
|
|
21
|
+
"toy_key": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Toy key (direct flow)"
|
|
24
|
+
},
|
|
25
|
+
"api_url": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "FoloToy API base URL (api flow)"
|
|
28
|
+
},
|
|
29
|
+
"api_key": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "FoloToy API key (api flow)"
|
|
32
|
+
},
|
|
33
|
+
"mqtt_host": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"description": "MQTT broker host"
|
|
36
|
+
},
|
|
37
|
+
"mqtt_port": {
|
|
38
|
+
"type": "number",
|
|
39
|
+
"description": "MQTT broker port"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"uiHints": {
|
|
44
|
+
"flow": { "label": "Auth Flow" },
|
|
45
|
+
"toy_sn": { "label": "Toy SN" },
|
|
46
|
+
"toy_key": { "label": "Toy Key", "sensitive": true },
|
|
47
|
+
"api_url": { "label": "API URL", "placeholder": "https://api.folotoy.cn" },
|
|
48
|
+
"api_key": { "label": "API Key", "sensitive": true },
|
|
49
|
+
"mqtt_host": { "label": "MQTT Host", "placeholder": "198.19.249.25" },
|
|
50
|
+
"mqtt_port": { "label": "MQTT Port", "placeholder": "1883" }
|
|
51
|
+
}
|
|
5
52
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@folotoy/folotoy-openclaw-plugin",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Empower your FoloToy with OpenClaw AI capabilities.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"folotoy",
|
|
7
7
|
"openclaw"
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"label": "FoloToy",
|
|
56
56
|
"selectionLabel": "FoloToy",
|
|
57
57
|
"docsPath": "/channels/folotoy",
|
|
58
|
-
"blurb": "
|
|
58
|
+
"blurb": "Empower your FoloToy with OpenClaw AI capabilities."
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest'
|
|
2
2
|
import { EventEmitter } from 'events'
|
|
3
|
-
import {
|
|
3
|
+
import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
|
|
4
4
|
|
|
5
5
|
// Replicate the message parsing logic from index.ts for unit testing
|
|
6
6
|
type InboundMessage = {
|
|
@@ -24,7 +24,7 @@ function makeMockClient() {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function setupSubscriber(client: ReturnType<typeof makeMockClient>, toy_sn: string, onMessage: (msgId: number, text: string) => void) {
|
|
27
|
-
const topic =
|
|
27
|
+
const topic = buildInboundTopic(toy_sn)
|
|
28
28
|
client.subscribe(topic, () => {})
|
|
29
29
|
client.on('message', (_topic: string, payload: Buffer) => {
|
|
30
30
|
if (_topic !== topic) return
|
|
@@ -40,7 +40,7 @@ function setupSubscriber(client: ReturnType<typeof makeMockClient>, toy_sn: stri
|
|
|
40
40
|
|
|
41
41
|
describe('inbound message parsing', () => {
|
|
42
42
|
const toy_sn = 'SN001'
|
|
43
|
-
const
|
|
43
|
+
const inboundTopic = buildInboundTopic(toy_sn)
|
|
44
44
|
|
|
45
45
|
it('calls onMessage with msgId and text on valid chat_input', () => {
|
|
46
46
|
const client = makeMockClient()
|
|
@@ -48,7 +48,7 @@ describe('inbound message parsing', () => {
|
|
|
48
48
|
setupSubscriber(client, toy_sn, onMessage)
|
|
49
49
|
|
|
50
50
|
const msg: InboundMessage = { msgId: 42, identifier: 'chat_input', outParams: { text: 'hello' } }
|
|
51
|
-
client.emit('message',
|
|
51
|
+
client.emit('message', inboundTopic, Buffer.from(JSON.stringify(msg)))
|
|
52
52
|
|
|
53
53
|
expect(onMessage).toHaveBeenCalledWith(42, 'hello')
|
|
54
54
|
})
|
|
@@ -59,7 +59,7 @@ describe('inbound message parsing', () => {
|
|
|
59
59
|
setupSubscriber(client, toy_sn, onMessage)
|
|
60
60
|
|
|
61
61
|
const msg: InboundMessage = { msgId: 1, identifier: 'chat_input', outParams: { text: 'hi' } }
|
|
62
|
-
client.emit('message', '/openapi/folotoy/OTHER/thing/
|
|
62
|
+
client.emit('message', '/openapi/folotoy/OTHER/thing/command/call', Buffer.from(JSON.stringify(msg)))
|
|
63
63
|
|
|
64
64
|
expect(onMessage).not.toHaveBeenCalled()
|
|
65
65
|
})
|
|
@@ -69,7 +69,7 @@ describe('inbound message parsing', () => {
|
|
|
69
69
|
const onMessage = vi.fn()
|
|
70
70
|
setupSubscriber(client, toy_sn, onMessage)
|
|
71
71
|
|
|
72
|
-
client.emit('message',
|
|
72
|
+
client.emit('message', inboundTopic, Buffer.from(JSON.stringify({ msgId: 1, identifier: 'other', outParams: { text: 'hi' } })))
|
|
73
73
|
|
|
74
74
|
expect(onMessage).not.toHaveBeenCalled()
|
|
75
75
|
})
|
|
@@ -79,7 +79,7 @@ describe('inbound message parsing', () => {
|
|
|
79
79
|
const onMessage = vi.fn()
|
|
80
80
|
setupSubscriber(client, toy_sn, onMessage)
|
|
81
81
|
|
|
82
|
-
client.emit('message',
|
|
82
|
+
client.emit('message', inboundTopic, Buffer.from('not json'))
|
|
83
83
|
|
|
84
84
|
expect(onMessage).not.toHaveBeenCalled()
|
|
85
85
|
})
|
|
@@ -87,7 +87,7 @@ describe('inbound message parsing', () => {
|
|
|
87
87
|
|
|
88
88
|
describe('outbound message format', () => {
|
|
89
89
|
const toy_sn = 'SN001'
|
|
90
|
-
const
|
|
90
|
+
const outboundTopic = buildOutboundTopic(toy_sn)
|
|
91
91
|
|
|
92
92
|
it('publishes chat_output with correct msgId and content', () => {
|
|
93
93
|
const client = makeMockClient()
|
|
@@ -98,11 +98,11 @@ describe('outbound message format', () => {
|
|
|
98
98
|
identifier: 'chat_output',
|
|
99
99
|
outParams: { content },
|
|
100
100
|
}
|
|
101
|
-
client.publish(
|
|
101
|
+
client.publish(outboundTopic, JSON.stringify(outMsg))
|
|
102
102
|
|
|
103
103
|
expect(client.publish).toHaveBeenCalledOnce()
|
|
104
104
|
const [t, payload] = client.publish.mock.calls[0] as [string, string]
|
|
105
|
-
expect(t).toBe(
|
|
105
|
+
expect(t).toBe(outboundTopic)
|
|
106
106
|
expect(JSON.parse(payload)).toEqual({ msgId: 42, identifier: 'chat_output', outParams: { content: 'world' } })
|
|
107
107
|
})
|
|
108
108
|
})
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import { buildInboundTopic, buildOutboundTopic } from '../mqtt.js'
|
|
3
3
|
|
|
4
|
-
describe('
|
|
5
|
-
it('builds the correct topic for a given SN', () => {
|
|
6
|
-
expect(
|
|
4
|
+
describe('buildInboundTopic', () => {
|
|
5
|
+
it('builds the correct inbound topic for a given SN', () => {
|
|
6
|
+
expect(buildInboundTopic('SN001')).toBe('/openapi/folotoy/SN001/thing/command/call')
|
|
7
|
+
})
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
describe('buildOutboundTopic', () => {
|
|
11
|
+
it('builds the correct outbound topic for a given SN', () => {
|
|
12
|
+
expect(buildOutboundTopic('SN001')).toBe('/openapi/folotoy/SN001/thing/command/callAck')
|
|
7
13
|
})
|
|
8
14
|
})
|
package/src/config.ts
CHANGED
|
@@ -19,5 +19,32 @@ export type PluginConfig = {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Flat config as stored in openclaw.json channels.folotoy */
|
|
23
|
+
export type FlatChannelConfig = {
|
|
24
|
+
flow?: string
|
|
25
|
+
toy_sn?: string
|
|
26
|
+
toy_key?: string
|
|
27
|
+
api_url?: string
|
|
28
|
+
api_key?: string
|
|
29
|
+
mqtt_host?: string
|
|
30
|
+
mqtt_port?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_API_URL = 'https://api.folotoy.cn'
|
|
22
34
|
export const DEFAULT_MQTT_HOST = process.env.FOLOTOY_MQTT_HOST ?? '198.19.249.25'
|
|
23
35
|
export const DEFAULT_MQTT_PORT = 1883
|
|
36
|
+
|
|
37
|
+
export function flatToPluginConfig(flat: FlatChannelConfig): PluginConfig {
|
|
38
|
+
const flow = flat.flow ?? 'direct'
|
|
39
|
+
const auth = flow === 'api'
|
|
40
|
+
? { flow: 'api' as const, api_url: flat.api_url ?? DEFAULT_API_URL, api_key: flat.api_key ?? '', toy_sn: flat.toy_sn ?? '' }
|
|
41
|
+
: { flow: 'direct' as const, toy_sn: flat.toy_sn ?? '', toy_key: flat.toy_key ?? '' }
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
auth,
|
|
45
|
+
mqtt: {
|
|
46
|
+
host: flat.mqtt_host ?? DEFAULT_MQTT_HOST,
|
|
47
|
+
port: flat.mqtt_port ?? DEFAULT_MQTT_PORT,
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import type { OpenClawPluginApi, ChannelPlugin } from 'openclaw/plugin-sdk/core'
|
|
2
|
-
import { resolveCredentials, createMqttClient,
|
|
3
|
-
import { DEFAULT_MQTT_HOST, DEFAULT_MQTT_PORT } from './config.js'
|
|
4
|
-
import type {
|
|
2
|
+
import { resolveCredentials, createMqttClient, buildInboundTopic, buildOutboundTopic } from './mqtt.js'
|
|
3
|
+
import { DEFAULT_MQTT_HOST, DEFAULT_MQTT_PORT, flatToPluginConfig } from './config.js'
|
|
4
|
+
import type { FlatChannelConfig } from './config.js'
|
|
5
5
|
import type { MqttClient } from 'mqtt'
|
|
6
6
|
|
|
7
|
-
type FoloToyAccount = PluginConfig
|
|
8
|
-
|
|
9
7
|
type InboundMessage = {
|
|
10
8
|
msgId: number
|
|
11
9
|
identifier: 'chat_input'
|
|
@@ -18,17 +16,17 @@ type OutboundMessage = {
|
|
|
18
16
|
outParams: { content: string }
|
|
19
17
|
}
|
|
20
18
|
|
|
21
|
-
// Per-account MQTT clients
|
|
22
|
-
const activeClients = new Map<string, { client: MqttClient; toy_sn: string }>()
|
|
19
|
+
// Per-account MQTT clients and msgId counters
|
|
20
|
+
const activeClients = new Map<string, { client: MqttClient; toy_sn: string; nextMsgId: number }>()
|
|
23
21
|
|
|
24
|
-
const folotoyChannel: ChannelPlugin<
|
|
22
|
+
const folotoyChannel: ChannelPlugin<FlatChannelConfig> = {
|
|
25
23
|
id: 'folotoy',
|
|
26
24
|
meta: {
|
|
27
25
|
id: 'folotoy',
|
|
28
26
|
label: 'FoloToy',
|
|
29
27
|
selectionLabel: 'FoloToy',
|
|
30
28
|
docsPath: '/channels/folotoy',
|
|
31
|
-
blurb: '
|
|
29
|
+
blurb: 'Empower your FoloToy with OpenClaw AI capabilities.',
|
|
32
30
|
},
|
|
33
31
|
capabilities: {
|
|
34
32
|
chatTypes: ['direct'],
|
|
@@ -37,60 +35,35 @@ const folotoyChannel: ChannelPlugin<FoloToyAccount> = {
|
|
|
37
35
|
schema: {
|
|
38
36
|
type: 'object',
|
|
39
37
|
properties: {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
toy_sn: { type: 'string' },
|
|
48
|
-
toy_key: { type: 'string' },
|
|
49
|
-
},
|
|
50
|
-
required: ['flow', 'toy_sn', 'toy_key'],
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
title: 'Flow 1: HTTP API Login',
|
|
54
|
-
properties: {
|
|
55
|
-
flow: { type: 'string', const: 'api' },
|
|
56
|
-
api_url: { type: 'string' },
|
|
57
|
-
api_key: { type: 'string' },
|
|
58
|
-
toy_sn: { type: 'string' },
|
|
59
|
-
},
|
|
60
|
-
required: ['flow', 'api_url', 'api_key', 'toy_sn'],
|
|
61
|
-
},
|
|
62
|
-
],
|
|
63
|
-
default: { flow: 'direct' },
|
|
64
|
-
},
|
|
65
|
-
mqtt: {
|
|
66
|
-
type: 'object',
|
|
67
|
-
properties: {
|
|
68
|
-
host: { type: 'string', default: DEFAULT_MQTT_HOST },
|
|
69
|
-
port: { type: 'number', default: DEFAULT_MQTT_PORT },
|
|
70
|
-
},
|
|
71
|
-
},
|
|
38
|
+
flow: { type: 'string', enum: ['direct', 'api'], default: 'direct' },
|
|
39
|
+
toy_sn: { type: 'string' },
|
|
40
|
+
toy_key: { type: 'string' },
|
|
41
|
+
api_url: { type: 'string', default: 'https://api.folotoy.cn' },
|
|
42
|
+
api_key: { type: 'string' },
|
|
43
|
+
mqtt_host: { type: 'string', default: DEFAULT_MQTT_HOST },
|
|
44
|
+
mqtt_port: { type: 'number', default: DEFAULT_MQTT_PORT },
|
|
72
45
|
},
|
|
73
|
-
required: ['auth'],
|
|
74
46
|
},
|
|
75
47
|
uiHints: {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
48
|
+
flow: { label: 'Auth Flow' },
|
|
49
|
+
toy_sn: { label: 'Toy SN' },
|
|
50
|
+
toy_key: { label: 'Toy Key', sensitive: true },
|
|
51
|
+
api_url: { label: 'API URL', placeholder: 'https://api.folotoy.com' },
|
|
52
|
+
api_key: { label: 'API Key', sensitive: true },
|
|
53
|
+
mqtt_host: { label: 'MQTT Host', placeholder: DEFAULT_MQTT_HOST },
|
|
54
|
+
mqtt_port: { label: 'MQTT Port' },
|
|
82
55
|
},
|
|
83
56
|
},
|
|
84
57
|
config: {
|
|
85
58
|
listAccountIds: (cfg) => {
|
|
86
|
-
const
|
|
87
|
-
.channels?.folotoy
|
|
88
|
-
return
|
|
59
|
+
const folotoy = (cfg as Record<string, unknown> & { channels?: { folotoy?: FlatChannelConfig } })
|
|
60
|
+
.channels?.folotoy
|
|
61
|
+
return folotoy ? ['default'] : []
|
|
89
62
|
},
|
|
90
|
-
resolveAccount: (cfg,
|
|
91
|
-
const
|
|
92
|
-
.channels?.folotoy
|
|
93
|
-
return
|
|
63
|
+
resolveAccount: (cfg, _accountId) => {
|
|
64
|
+
const folotoy = (cfg as Record<string, unknown> & { channels?: { folotoy?: FlatChannelConfig } })
|
|
65
|
+
.channels?.folotoy
|
|
66
|
+
return folotoy ?? ({} as FlatChannelConfig)
|
|
94
67
|
},
|
|
95
68
|
},
|
|
96
69
|
gateway: {
|
|
@@ -102,22 +75,21 @@ const folotoyChannel: ChannelPlugin<FoloToyAccount> = {
|
|
|
102
75
|
return
|
|
103
76
|
}
|
|
104
77
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
host: account.mqtt?.host ?? DEFAULT_MQTT_HOST,
|
|
109
|
-
port: account.mqtt?.port ?? DEFAULT_MQTT_PORT,
|
|
110
|
-
},
|
|
78
|
+
if (!account.toy_sn) {
|
|
79
|
+
log?.warn?.('toy_sn not configured — skipping MQTT connection')
|
|
80
|
+
return
|
|
111
81
|
}
|
|
112
82
|
|
|
83
|
+
const mqttConfig = flatToPluginConfig(account)
|
|
113
84
|
const credentials = await resolveCredentials(mqttConfig)
|
|
114
85
|
const client = await createMqttClient(mqttConfig, credentials)
|
|
115
|
-
const
|
|
86
|
+
const inboundTopic = buildInboundTopic(credentials.toy_sn)
|
|
87
|
+
const outboundTopic = buildOutboundTopic(credentials.toy_sn)
|
|
116
88
|
|
|
117
|
-
activeClients.set(accountId, { client, toy_sn: credentials.toy_sn })
|
|
118
|
-
log?.info?.(`Connected to MQTT broker, subscribed to ${
|
|
89
|
+
activeClients.set(accountId, { client, toy_sn: credentials.toy_sn, nextMsgId: 1 })
|
|
90
|
+
log?.info?.(`Connected to MQTT broker, subscribed to ${inboundTopic}`)
|
|
119
91
|
|
|
120
|
-
client.subscribe(
|
|
92
|
+
client.subscribe(inboundTopic, (err) => {
|
|
121
93
|
if (err) log?.error?.(`Failed to subscribe: ${err.message}`)
|
|
122
94
|
})
|
|
123
95
|
|
|
@@ -153,17 +125,21 @@ const folotoyChannel: ChannelPlugin<FoloToyAccount> = {
|
|
|
153
125
|
identifier: 'chat_output',
|
|
154
126
|
outParams: { content: replyPayload.text },
|
|
155
127
|
}
|
|
156
|
-
client.publish(
|
|
128
|
+
client.publish(outboundTopic, JSON.stringify(outMsg))
|
|
157
129
|
},
|
|
158
130
|
onError: (err) => log?.error?.(`Dispatch error: ${String(err)}`),
|
|
159
131
|
},
|
|
160
132
|
})
|
|
161
133
|
})
|
|
162
134
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
135
|
+
// Keep the account alive until aborted
|
|
136
|
+
return new Promise<void>((resolve) => {
|
|
137
|
+
abortSignal.addEventListener('abort', () => {
|
|
138
|
+
activeClients.delete(accountId)
|
|
139
|
+
client.end()
|
|
140
|
+
log?.info?.('MQTT client disconnected')
|
|
141
|
+
resolve()
|
|
142
|
+
})
|
|
167
143
|
})
|
|
168
144
|
},
|
|
169
145
|
|
|
@@ -179,14 +155,14 @@ const folotoyChannel: ChannelPlugin<FoloToyAccount> = {
|
|
|
179
155
|
const entry = activeClients.get(key)
|
|
180
156
|
if (!entry) throw new Error(`No active MQTT client for account "${key}"`)
|
|
181
157
|
|
|
182
|
-
const
|
|
183
|
-
const msgId =
|
|
158
|
+
const outboundTopic = buildOutboundTopic(entry.toy_sn)
|
|
159
|
+
const msgId = entry.nextMsgId++
|
|
184
160
|
const outMsg: OutboundMessage = {
|
|
185
161
|
msgId,
|
|
186
162
|
identifier: 'chat_output',
|
|
187
163
|
outParams: { content: text },
|
|
188
164
|
}
|
|
189
|
-
entry.client.publish(
|
|
165
|
+
entry.client.publish(outboundTopic, JSON.stringify(outMsg))
|
|
190
166
|
return { channel: 'folotoy', messageId: String(msgId) }
|
|
191
167
|
},
|
|
192
168
|
},
|
package/src/mqtt.ts
CHANGED
|
@@ -44,8 +44,12 @@ export async function resolveCredentials(config: PluginConfig): Promise<MqttCred
|
|
|
44
44
|
return directCredentials(config.auth)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function
|
|
48
|
-
return `/openapi/folotoy/${toy_sn}/thing/
|
|
47
|
+
export function buildInboundTopic(toy_sn: string): string {
|
|
48
|
+
return `/openapi/folotoy/${toy_sn}/thing/command/call`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildOutboundTopic(toy_sn: string): string {
|
|
52
|
+
return `/openapi/folotoy/${toy_sn}/thing/command/callAck`
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
export async function createMqttClient(config: PluginConfig, credentials: MqttCredentials): Promise<MqttClient> {
|