{
79 | if (this.streaming) {
80 | throw new Error('already playing')
81 | }
82 | this.streaming = true
83 |
84 | const host = this.host
85 | const port = this.port
86 | const speakerId = this.speakerId
87 | await this.getQuery(key, speakerId)
88 | const { onPlayed, onDone, audio } = this
89 | const file = new File(QUERY_PATH)
90 | trace(`file opened. length: ${file.length}, position: ${file.position}`)
91 | return new Promise((resolve, reject) => {
92 | let streamer = new WavStreamer({
93 | http: device.network.http,
94 | host,
95 | port,
96 | path: encodeURI(`/synthesis?speaker=${speakerId}`),
97 | audio: {
98 | out: audio,
99 | stream: 0,
100 | },
101 | bufferDuration: 600,
102 | request: {
103 | method: 'POST',
104 | headers: new Map([
105 | ['content-type', 'application/json'],
106 | ['content-length', `${file.length}`],
107 | ]),
108 | onWritable(count) {
109 | this.write(file.read(ArrayBuffer, count))
110 | },
111 | },
112 | onPlayed(buffer) {
113 | const power = calculatePower(buffer)
114 | onPlayed?.(power)
115 | },
116 | onReady(state) {
117 | trace(`Ready: ${state}\n`)
118 | if (state) {
119 | audio.start()
120 | } else {
121 | audio.stop()
122 | }
123 | },
124 | onError: (e) => {
125 | file.close()
126 | trace('ERROR: ', e, '\n')
127 | this.streaming = false
128 | reject(e)
129 | },
130 | onDone: () => {
131 | file.close()
132 | trace('DONE\n')
133 | this.streaming = false
134 | streamer?.close()
135 | onDone?.()
136 | resolve()
137 | },
138 | })
139 | })
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/firmware/stackchan/touch.ts:
--------------------------------------------------------------------------------
1 | import config from 'mc/config'
2 | import Timer from 'timer'
3 | import Time from 'time'
4 |
5 | export default class Touch {
6 | onTouchBegan: (x: number, y: number, ticks: number) => void
7 | onTouchMoved: (x: number, y: number, ticks: number) => void
8 | onTouchEnded: (x: number, y: number, ticks: number) => void
9 |
10 | constructor() {
11 | let touch = new config.Touch()
12 | touch.points = [{}]
13 |
14 | Timer.repeat(() => {
15 | const points = touch.points
16 | touch.read(points)
17 | const point = points[0]
18 | switch (point.state) {
19 | case 0:
20 | case 3:
21 | if (point.down) {
22 | delete point.down
23 | this.onTouchEnded?.(point.x, point.y, Time.ticks)
24 | delete point.x
25 | delete point.y
26 | }
27 | break
28 | case 1:
29 | case 2:
30 | if (!point.down) {
31 | point.down = true
32 | this.onTouchBegan?.(point.x, point.y, Time.ticks)
33 | } else this.onTouchMoved?.(point.x, point.y, Time.ticks)
34 | break
35 | }
36 | }, 15)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/firmware/stackchan/utilities/consts.ts:
--------------------------------------------------------------------------------
1 | export const DOMAIN = {
2 | wifi: 'wifi',
3 | driver: 'driver',
4 | renderer: 'renderer',
5 | tts: 'tts',
6 | ai: 'ai',
7 | } as const
8 |
9 | export const PREF_KEYS: readonly [keyof typeof DOMAIN, string, StringConstructor | NumberConstructor][] = Object.freeze(
10 | [
11 | [DOMAIN.wifi, 'ssid', String],
12 | [DOMAIN.wifi, 'password', String],
13 | [DOMAIN.renderer, 'type', String],
14 | [DOMAIN.driver, 'type', String],
15 | [DOMAIN.driver, 'baudrate', Number],
16 | [DOMAIN.driver, 'offsetPan', Number],
17 | [DOMAIN.driver, 'offsetTilt', Number],
18 | [DOMAIN.tts, 'type', String],
19 | [DOMAIN.tts, 'host', String],
20 | [DOMAIN.tts, 'port', Number],
21 | [DOMAIN.tts, 'token', String],
22 | [DOMAIN.ai, 'token', String],
23 | [DOMAIN.ai, 'context', String],
24 | ],
25 | true
26 | )
27 |
28 | export const DEFAULT_FONT = 'OpenSans-Regular-24.bf4'
29 |
--------------------------------------------------------------------------------
/firmware/stackchan/utilities/manifest_utility.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "$(MODDABLE)/examples/manifest_base.json",
4 | "$(MODULES)/files/preference/manifest.json",
5 | "$(MODULES)/base/structuredClone/manifest.json",
6 | "$(MODULES)/base/deepEqual/manifest.json",
7 | "$(MODDABLE)/examples/manifest_typings.json"
8 | ],
9 | "modules": {
10 | "*": [
11 | "./*"
12 | ]
13 | },
14 | "preload": [
15 | "consts",
16 | "stackchan-util"
17 | ]
18 | }
--------------------------------------------------------------------------------
/firmware/tests/drivers/dynamixel/main.ts:
--------------------------------------------------------------------------------
1 | import { DynamixelDriver } from 'dynamixel-driver'
2 |
3 | const driver = new DynamixelDriver({
4 | panId: 1,
5 | tiltId: 2,
6 | baud: 1_000_000,
7 | })
8 |
9 | driver.applyRotation({
10 | r: 0,
11 | p: 0,
12 | y: 0,
13 | })
14 |
15 |
16 |
--------------------------------------------------------------------------------
/firmware/tests/drivers/dynamixel/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "$(MODDABLE)/examples/manifest_base.json",
4 | "$(MODDABLE)/examples/manifest_typings.json",
5 | "../../../stackchan/drivers/manifest_driver.json"
6 | ],
7 | "modules": {
8 | "*": [
9 | "./main"
10 | ]
11 | },
12 | "platforms":{
13 | "esp32": {
14 | "config" :{
15 | "startupSound": false
16 | }
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/firmware/tests/drivers/serial/main.ts:
--------------------------------------------------------------------------------
1 | import Serial from 'embedded:io/serial'
2 | import Timer from 'timer'
3 | import Digital from "pins/digital";
4 |
5 | let buffer = new ArrayBuffer(1);
6 | let chars = new Uint8Array(buffer);
7 | let blink =1;
8 | let led = new Digital(17,Digital.Output);
9 |
10 | chars[0] = 0x55;
11 |
12 |
13 | let serial = new Serial(
14 | {
15 | transmit: 7,
16 | receive: 6,
17 | baud: 1000000,
18 | port: 1
19 | }
20 | );
21 |
22 |
23 | Timer.repeat(id => {
24 | serial.write(buffer);
25 | blink = blink ^1;
26 | led.write(blink);
27 | trace("send\n\r");
28 | }, 1);
29 |
30 |
--------------------------------------------------------------------------------
/firmware/tests/drivers/serial/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "$(MODDABLE)/examples/manifest_base.json",
4 | "$(MODDABLE)/examples/manifest_typings.json"
5 | ],
6 | "modules": {
7 | "*": [
8 | "./main"
9 | ]
10 | },
11 | "platforms":{
12 | "esp32": {
13 | "config" :{
14 | "startupSound": false
15 | }
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/firmware/tests/renderers/render-balloon/main.ts:
--------------------------------------------------------------------------------
1 | import { Renderer } from 'dog-face'
2 | import { defaultFaceContext } from 'renderer-base'
3 | import { createBalloonDecorator } from 'decorator'
4 | import Poco from 'commodetto/Poco'
5 | import Timer from 'timer'
6 | import Resource from 'Resource'
7 | import parseBMF from 'commodetto/parseBMF'
8 | import structuredClone from 'structuredClone'
9 |
10 | const font = parseBMF(new Resource('NotoSansJP-Regular-24.bf4'))
11 | let poco = new Poco(screen, { rotation: 90, displayListLength: 1024 })
12 | const renderer = new Renderer({ poco })
13 | type Color = [number, number, number]
14 | const black: Color = [0, 0, 0]
15 | const brown: Color = [255, 146, 0]
16 |
17 | const balloon = createBalloonDecorator({
18 | bottom: 5,
19 | right: 10,
20 | width: 120,
21 | height: font.height,
22 | font,
23 | text: 'じゅげむじゅげむごこうのすりきれかいじゃりすいぎょのすいぎょうまつふうらいまつ...'
24 | })
25 | renderer.addDecorator(balloon)
26 |
27 | const INTERVAL = 1000 / 30
28 | const context = structuredClone(defaultFaceContext)
29 | context.theme.primary = black
30 | context.theme.secondary = brown
31 | let count = 0
32 | Timer.repeat(() => {
33 | count = (count + 30) % 360
34 | context.mouth.open = Math.sin((Math.PI * 2 * count) / 360) / 2 + 0.5
35 |
36 | renderer.update(INTERVAL, context)
37 | }, INTERVAL)
38 |
--------------------------------------------------------------------------------
/firmware/tests/renderers/render-balloon/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "$(MODDABLE)/examples/manifest_base.json",
4 | "$(MODDABLE)/examples/manifest_typings.json",
5 | "../../../stackchan/renderers/manifest_renderer.json"
6 | ],
7 | "modules": {
8 | "*": [
9 | "./main"
10 | ]
11 | },
12 | "resources": {
13 | "*-mask": [
14 | {
15 | "source": "$(MODDABLE)/examples/assets/scalablefonts/NotoSans/NotoSansJP-Regular",
16 | "size": 24,
17 | "blocks": [
18 | "Hiragana",
19 | "Katakana",
20 | "Basic Latin"
21 | ],
22 | "characters": "えっ今からでも入れる保険があるんですか!?"
23 | }
24 | ]
25 | },
26 | "config": {
27 | "rotation": 90
28 | }
29 | }
--------------------------------------------------------------------------------
/firmware/tests/renderers/render-face/main.ts:
--------------------------------------------------------------------------------
1 | import {Renderer} from 'simple-face'
2 | import Timer from 'timer'
3 |
4 | const INTERVAL = 1000 / 30
5 | const renderer = new Renderer({})
6 | Timer.repeat(() => {
7 | renderer.update(INTERVAL)
8 | }, INTERVAL)
9 |
--------------------------------------------------------------------------------
/firmware/tests/renderers/render-face/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "$(MODDABLE)/examples/manifest_base.json",
4 | "$(MODDABLE)/examples/manifest_typings.json",
5 | "../../../stackchan/renderers/manifest_renderer.json"
6 | ],
7 | "modules": {
8 | "*": [
9 | "./main"
10 | ]
11 | },
12 | "config": {
13 | "rotation": 90
14 | }
15 | }
--------------------------------------------------------------------------------
/firmware/tests/services/network-service/main.ts:
--------------------------------------------------------------------------------
1 | import { NetworkService } from 'network-service'
2 |
3 | const service = new NetworkService({
4 | ssid: 'myssid',
5 | password: 'mypassword',
6 | })
7 |
8 | service.connect(
9 | () => {
10 | trace('connected\n')
11 | },
12 | (message) => {
13 | trace(`error: ${message}\n`)
14 | }
15 | )
16 |
--------------------------------------------------------------------------------
/firmware/tests/services/network-service/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "$(MODDABLE)/examples/manifest_base.json",
4 | "$(MODDABLE)/examples/manifest_typings.json",
5 | "../../../stackchan/services/manifest_service.json"
6 | ],
7 | "modules": {
8 | "*": [
9 | "./main"
10 | ]
11 | }
12 | }
--------------------------------------------------------------------------------
/firmware/tests/speeches/tts-elevenlabs/main.ts:
--------------------------------------------------------------------------------
1 | import { TTS } from 'tts-elevenlabs'
2 | import Timer from 'timer'
3 |
4 | const token = 'YOUR_API_KEY_HERE'
5 | const tts = new TTS({
6 | token,
7 | onPlayed: (num) => {
8 | trace(`played ${num}\n`)
9 | },
10 | onDone: () => {
11 | trace('done\n')
12 | }
13 | })
14 |
15 | async function main() {
16 | while (true) {
17 | await tts.stream('Hello. I am Stack-chan. Nice to meet you.')
18 | Timer.delay(2000)
19 | }
20 | }
21 |
22 | main()
23 |
--------------------------------------------------------------------------------
/firmware/tests/speeches/tts-elevenlabs/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "$(MODDABLE)/examples/manifest_base.json",
4 | "$(MODDABLE)/examples/manifest_typings.json",
5 | "../../../stackchan/speeches/manifest_speech.json"
6 | ],
7 | "modules": {
8 | "*": [
9 | "./main"
10 | ]
11 | }
12 | }
--------------------------------------------------------------------------------
/firmware/tests/speeches/tts-local/main.ts:
--------------------------------------------------------------------------------
1 | import { TTS } from 'tts-local'
2 | import Timer from 'timer'
3 |
4 | const tts = new TTS({
5 | onPlayed: (num) => {
6 | trace(`played ${num}\n`)
7 | },
8 | onDone: () => {
9 | trace('done\n')
10 | }
11 | })
12 |
13 | while (true) {
14 | await tts.stream('wilhelm-scream')
15 | Timer.delay(2000)
16 | }
17 |
--------------------------------------------------------------------------------
/firmware/tests/speeches/tts-local/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "$(MODDABLE)/examples/manifest_base.json",
4 | "$(MODDABLE)/examples/manifest_typings.json",
5 | "../../../stackchan/speeches/manifest_speech.json"
6 | ],
7 | "resources": {
8 | "*": "$(MODDABLE)/examples/assets/sounds/*"
9 | },
10 | "modules": {
11 | "*": [
12 | "./main"
13 | ]
14 | },
15 | "defines": {
16 | "main": {
17 | "async": 1
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/firmware/typings/btutils.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @note type definitions of `btutils` exists in moddable/typings/ble.d.ts but cannot import
3 | */
4 | declare module 'btutils' {
5 | export class Bytes extends ArrayBuffer {
6 | constructor(bytes: string | ArrayBufferLike, littleEndian?: boolean)
7 | equals(bytes: Bytes): boolean
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/firmware/typings/elevenlabsstreamer.d.ts:
--------------------------------------------------------------------------------
1 | declare module "elevenlabsstreamer" {
2 | import type AudioOut from "pins/audioout"
3 | type ElevenLabsStreamerOptions = {
4 | key: string,
5 | voice?: string,
6 | latency?: number,
7 | text: string,
8 | model?: string,
9 | audio: {
10 | out: AudioOut,
11 | sampleRate?: number,
12 | stream: number,
13 | },
14 | onPlayed?: (buffer: ArrayBuffer) => void
15 | onReady?: (state: boolean) => void
16 | onError?: (message: string) => void
17 | onDone?: () => void
18 | }
19 | export default class ElevenLabsStreamer {
20 | constructor(options: ElevenLabsStreamerOptions);
21 | close(): void;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/firmware/typings/fetch.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'fetch' {
2 | export function fetch(...args: any): any
3 | export class Headers {
4 | constructor(params: Array<[string, string]>)
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/firmware/typings/piu/All.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'piu/All' {
2 | function hsl(h: number, s: number, l: number): any
3 | }
--------------------------------------------------------------------------------
/firmware/typings/resourcestreamer.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'resourcestreamer' {
2 | import type AudioOut from 'pins/audioout'
3 | type ResourceStreamerOptions = {
4 | path: string
5 | audio: {
6 | out: AudioOut
7 | sampleRate: number
8 | stream: number
9 | }
10 | onPlayed?: (buffer: ArrayBuffer) => void
11 | onReady?: (state: boolean) => void
12 | onError?: (message: string) => void
13 | onDone?: () => void
14 | }
15 | export default class ResourceStreamer {
16 | audio: AudioOut
17 | constructor(options: ResourceStreamerOptions)
18 | close(): void
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/firmware/typings/uartserver.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'uartserver' {
2 | class UARTServer {
3 | deviceName: string
4 | notifyValue(characteristic: string, data: ArrayBuffer): void
5 | onConnected(): void
6 | onDisconnected(): void
7 | onRX(data: ArrayBuffer): void
8 | startAdvertising(params: unknown): void
9 | }
10 |
11 | const SERVICE_UUID: string
12 |
13 | export { UARTServer, SERVICE_UUID }
14 | }
15 |
--------------------------------------------------------------------------------
/firmware/typings/url.d.ts:
--------------------------------------------------------------------------------
1 | declare module "url" {
2 | export class URLSearchParams {
3 | constructor(params: Array<[string, string]>)
4 | }
5 | }
--------------------------------------------------------------------------------
/firmware/typings/wavstreamer.d.ts:
--------------------------------------------------------------------------------
1 | declare module "wavstreamer" {
2 | import type AudioOut from "pins/audioout"
3 | import HTTPClient from "embedded:network/http/client";
4 | type WavStreamerOptions = {
5 | http: typeof HTTPClient.constructor
6 | host: string,
7 | port: number,
8 | path: string,
9 | audio: {
10 | out: AudioOut,
11 | sampleRate?: number,
12 | stream: number,
13 | },
14 | bufferDuration?: number,
15 | request?: any,
16 | waveHeaderBytes?: number,
17 | onPlayed?: (buffer: ArrayBuffer) => void
18 | onReady?: (state: boolean) => void
19 | onError?: (message: string) => void
20 | onDone?: () => void
21 | }
22 | export default class WavStreamer {
23 | constructor(options: WavStreamerOptions);
24 | close(): void;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/firmware/workspace.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "."
5 | },
6 | {
7 | "path": "../../../root/Projects/moddable"
8 | }
9 | ],
10 | "settings": {}
11 | }
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "test_stack_chan",
3 | "lockfileVersion": 3,
4 | "requires": true,
5 | "packages": {}
6 | }
7 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 |
--------------------------------------------------------------------------------
/web/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "tabWidth": 2,
4 | "semi": false,
5 | "singleQuote": true,
6 | "trailingComma": "es5"
7 | }
8 |
--------------------------------------------------------------------------------
/web/flash/flash.css:
--------------------------------------------------------------------------------
1 | .app {
2 | font-size: 1.8em;
3 | justify-content:center;
4 | max-width: 300px;
5 | gap: .5em;
6 | margin: auto;
7 | }
8 | .select-target {
9 | max-height: 2.5em;
10 | width: 100%;
11 | }
12 | .button-flash-container {
13 | width: 100%;
14 | }
15 | .button-flash {
16 | width: 100%;
17 | }
--------------------------------------------------------------------------------
/web/flash/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Flash firmware
9 |
10 |
11 |
12 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
36 | Ah snap, your browser doesn't work!
37 | Ah snap, you are not allowed to use this on HTTP!
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/web/flash/manifest_esp32_m5stack fire.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Stack-chan",
3 | "version": "1.0.0",
4 | "builds": [
5 | {
6 | "chipFamily": "ESP32",
7 | "parts": [
8 | {
9 | "path": "tech.moddable.stackchan/com.m5stack.fire/bootloader.bin",
10 | "offset": 4096
11 | },
12 | {
13 | "path": "tech.moddable.stackchan/com.m5stack.fire/partition-table.bin",
14 | "offset": 32768
15 | },
16 | {
17 | "path": "tech.moddable.stackchan/com.m5stack.fire/xs_esp32.bin",
18 | "offset": 65536
19 | }
20 | ]
21 | }
22 | ]
23 | }
--------------------------------------------------------------------------------
/web/flash/manifest_esp32_m5stack.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Stack-chan",
3 | "version": "1.0.0",
4 | "builds": [
5 | {
6 | "chipFamily": "ESP32",
7 | "parts": [
8 | {
9 | "path": "tech.moddable.stackchan/com.m5stack/bootloader.bin",
10 | "offset": 4096
11 | },
12 | {
13 | "path": "tech.moddable.stackchan/com.m5stack/partition-table.bin",
14 | "offset": 32768
15 | },
16 | {
17 | "path": "tech.moddable.stackchan/com.m5stack/xs_esp32.bin",
18 | "offset": 65536
19 | }
20 | ]
21 | }
22 | ]
23 | }
--------------------------------------------------------------------------------
/web/flash/manifest_esp32_m5stack_core2.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Stack-chan",
3 | "version": "1.0.0",
4 | "builds": [
5 | {
6 | "chipFamily": "ESP32",
7 | "parts": [
8 | {
9 | "path": "tech.moddable.stackchan/com.m5stack.core2/bootloader.bin",
10 | "offset": 4096
11 | },
12 | {
13 | "path": "tech.moddable.stackchan/com.m5stack.core2/partition-table.bin",
14 | "offset": 32768
15 | },
16 | {
17 | "path": "tech.moddable.stackchan/com.m5stack.core2/xs_esp32.bin",
18 | "offset": 65536
19 | }
20 | ]
21 | }
22 | ]
23 | }
--------------------------------------------------------------------------------
/web/flash/manifest_esp32_m5stack_cores3.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Stack-chan",
3 | "version": "2.1.1",
4 | "builds": [
5 | {
6 | "chipFamily": "ESP32-S3",
7 | "parts": [
8 | {
9 | "path": "tech.moddable.stackchan/com.m5stack.cores3/bootloader.bin",
10 | "offset": 0
11 | },
12 | {
13 | "path": "tech.moddable.stackchan/com.m5stack.cores3/partition-table.bin",
14 | "offset": 32768
15 | },
16 | {
17 | "path": "tech.moddable.stackchan/com.m5stack.cores3/xs_esp32.bin",
18 | "offset": 65536
19 | }
20 | ]
21 | }
22 | ]
23 | }
--------------------------------------------------------------------------------
/web/global.css:
--------------------------------------------------------------------------------
1 | html,body {
2 | height: 100%;
3 | margin: 0;
4 | padding: 0;
5 | }
6 | body {
7 | font-family: 'Roboto', sans-serif;
8 | color: #212121;
9 | background-color: #f5f5f5;
10 | }
11 |
12 | .app {
13 | width: 100%;
14 | height: 100%;
15 | display: flex;
16 | flex-direction: column;
17 | align-items: center;
18 | justify-content: space-around;
19 | }
20 |
21 | .card {
22 | position: relative;
23 | background-color: white;
24 | padding: 1.4em;
25 | border-radius: 8px;
26 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.26);
27 | width: 100%;
28 | max-width: 600px;
29 | }
30 |
31 | button {
32 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.26);
33 | }
34 |
35 | .card button {
36 | box-shadow: none;
37 | }
38 |
39 | .card h1,h2,h3,h4,h5,h6 {
40 | margin: 0.3em 0;
41 | }
42 |
43 | .form {
44 | display: flex;
45 | flex-direction: column;
46 | }
47 |
48 | .form-group {
49 | display: flex;
50 | align-items: center;
51 | justify-content: space-between;
52 | margin-bottom: 1em;
53 | }
54 |
55 | .form-group label {
56 | flex: 1;
57 | text-align: right;
58 | margin-right: 1em;
59 | }
60 |
61 | .form-group input,select,textarea {
62 | flex: 2;
63 | padding: 0.5em;
64 | }
65 |
66 | .card input,select,textarea {
67 | border: 1px solid #bdbdbd;
68 | border-radius: 4px;
69 | }
70 |
71 | button {
72 | background-color: #2196F3;
73 | color: white;
74 | padding: 0.7em 1em;
75 | border: none;
76 | border-radius: 4px;
77 | cursor: pointer;
78 | transition: all 0.3s cubic-bezier(.25, .8, .25, 1);
79 | }
80 |
81 | button:hover {
82 | background-color: #1976D2;
83 | }
84 |
85 | .toast {
86 | position: absolute;
87 | font-size: 1.2em;
88 | padding: 0.4em;
89 | top: 0;
90 | right: 0;
91 | max-width: 400px;
92 | background-color: #388E3C;
93 | color: white;
94 | visibility: hidden;
95 | transform: translateY(-100px);
96 | }
97 |
98 | .toast.visible {
99 | visibility: visible;
100 | animation: toast 3s ease 1 normal;
101 | }
102 |
103 | @keyframes toast {
104 | 33% {
105 | transform: translateY(0);
106 | }
107 | 66% {
108 | transform: translateY(0);
109 | }
110 | }
--------------------------------------------------------------------------------
/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Stack-chan dev server
9 |
10 |
11 |
12 | Stack-chan development page
13 |
14 | - Flash: Flash Stack-chan firmware
15 | - Preference: Set Stack-chan's preferences via BLE
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "stackchan-web",
3 | "version": "0.1.0",
4 | "description": "Stack-chan development server",
5 | "scripts": {
6 | "dev": "live-server --verbose"
7 | },
8 | "keywords": [],
9 | "author": "Shinya Ishikawa",
10 | "devDependencies": {
11 | "live-server": "^1.2.2",
12 | "prettier": "^3.0.0"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/web/preference/ble-client.mjs:
--------------------------------------------------------------------------------
1 | /**
2 | * A simple BLE UART Client
3 | */
4 | function isBluetoothAvailable() {
5 | return navigator.bluetooth != null
6 | }
7 |
8 | const SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
9 | const RX_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'
10 | const TX_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
11 |
12 | class SimpleBLEClient {
13 | #deviceName
14 | #encoder = new TextEncoder()
15 | #decoder = new TextDecoder()
16 |
17 | #device
18 | #onCharacteristicValueChanged
19 | #tx_characteristic
20 | #rx_characteristic
21 | constructor({ deviceName, onCharacteristicValueChanged }) {
22 | this.#deviceName = deviceName
23 | this.#onCharacteristicValueChanged = onCharacteristicValueChanged
24 | }
25 |
26 | async connect() {
27 | if (!isBluetoothAvailable()) {
28 | throw 'Bluetooth not available'
29 | }
30 | if (this.#device != null) {
31 | await this.disconnect()
32 | }
33 | const device = (this.#device = await navigator.bluetooth.requestDevice({
34 | acceptAllDevices: false,
35 | filters: [
36 | {
37 | name: this.#deviceName,
38 | },
39 | {
40 | services: [SERVICE_UUID],
41 | },
42 | ],
43 | }))
44 | console.log('device found')
45 | if (device.gatt == null) {
46 | throw 'The device has no gatt property'
47 | }
48 | device.addEventListener('gattserverdisconnected', () => {
49 | console.warn('Disconnected')
50 | this.onDisconnected?.()
51 | })
52 |
53 | const server = await device.gatt.connect()
54 | if (server == null) {
55 | throw 'Gatt connection failed'
56 | }
57 |
58 | const service = await server.getPrimaryService(SERVICE_UUID)
59 | this.#rx_characteristic = await service.getCharacteristic(RX_UUID)
60 | this.#tx_characteristic = await service.getCharacteristic(TX_UUID)
61 | this.#tx_characteristic.addEventListener('characteristicvaluechanged', (event) => {
62 | const value = event.target.value
63 | const str = this.#decoder.decode(value)
64 | const obj = JSON.parse(str)
65 | this.#onCharacteristicValueChanged?.(obj)
66 | })
67 | await this.#tx_characteristic.startNotifications()
68 | }
69 |
70 | isConnected() {
71 | return this.#device?.gatt?.connected ?? false
72 | }
73 |
74 | async disconnect() {
75 | this.#device?.gatt?.disconnect()
76 | }
77 |
78 | async send(obj) {
79 | const buf = this.#encoder.encode(JSON.stringify(obj))
80 | const chunkSize = 128 // Choose an appropriate chunk size
81 |
82 | for (let i = 0; i < buf.length; i += chunkSize) {
83 | const chunk = buf.slice(i, i + chunkSize)
84 | await this.#rx_characteristic?.writeValue(chunk).catch((reason) => {
85 | console.warn(`write failed: ${reason}`)
86 | })
87 | }
88 | }
89 | }
90 |
91 | export default SimpleBLEClient
92 |
--------------------------------------------------------------------------------
/web/preference/preference.css:
--------------------------------------------------------------------------------
1 | .accordion {
2 | display: none;
3 | }
4 |
5 | .accordion.active {
6 | display: block;
7 | }
8 |
9 | .ble-disconnect-button {
10 | position: absolute;
11 | right: 0.5em;
12 | top: 0.5em;
13 | font-size: 1.2em;
14 | color: #bdbdbd;
15 | cursor: pointer;
16 | }
--------------------------------------------------------------------------------