From c2ad3e59e19db78725ad4a7acfccb5140a3390f8 Mon Sep 17 00:00:00 2001 From: STC Date: Tue, 23 Dec 2025 08:59:37 +0900 Subject: [PATCH 1/5] add led --- firmware/stackchan/led/led.ts | 91 +++++++++++++++++++ firmware/stackchan/led/manifest_led.json | 27 ++++++ firmware/stackchan/led/neopixel_stub.ts | 31 +++++++ firmware/stackchan/main.ts | 11 +++ firmware/stackchan/manifest.json | 1 + firmware/stackchan/robot.ts | 78 ++++++++++++++++ .../stackchan/utilities/manifest_utility.json | 1 + firmware/tests/led/main.ts | 25 +++++ firmware/tests/led/manifest.json | 16 ++++ firmware/tsconfig.json | 1 + 10 files changed, 282 insertions(+) create mode 100644 firmware/stackchan/led/led.ts create mode 100644 firmware/stackchan/led/manifest_led.json create mode 100644 firmware/stackchan/led/neopixel_stub.ts create mode 100644 firmware/tests/led/main.ts create mode 100644 firmware/tests/led/manifest.json diff --git a/firmware/stackchan/led/led.ts b/firmware/stackchan/led/led.ts new file mode 100644 index 00000000..04ead4a7 --- /dev/null +++ b/firmware/stackchan/led/led.ts @@ -0,0 +1,91 @@ +import { NeoStrand, type NeoStrandEffect } from 'neostrand' +import Timer from 'timer' + +const Timing_WS2812B = { + mark: { level0: 1, duration0: 900, level1: 0, duration1: 350 }, + space: { level0: 1, duration0: 350, level1: 0, duration1: 900 }, + reset: { level0: 0, duration0: 100, level1: 0, duration1: 100 }, +} as const + +export default class Led extends NeoStrand { + private _blinkTimer?: Timer + private _blinking: boolean = false + private _blinkState: boolean = false + private _effect?: NeoStrandEffect + + constructor(parameters: { + pin: number + length?: number + order?: string + }) { + super({ + pin: parameters.pin, + length: parameters.length ?? 1, + order: parameters.order ?? 'GRB', + timing: Timing_WS2812B, + }) + } + private _fill(color: number, index: number, count: number) { + for (let i = index; i < index + count; i++) { + this.set(i, color) + } + } + + on(r: number, g: number, b: number, duration?: number, index?: number, count?: number) { + const _index = index ?? 0 + const _count = count ?? this.length - _index + this._fill(this.makeRGB(r, g, b), _index, _count) + + this.update() + if (duration) { + Timer.set(() => { + this._fill(this.makeRGB(0, 0, 0), _index, _count) + this.update() + }, duration) + } + } + off(index?: number, count?: number) { + const _index = index ?? 0 + const _count = count ?? this.length - _index + this._blinking = false + if (this._blinkTimer !== undefined) { + Timer.clear(this._blinkTimer) + this._blinkTimer = undefined + } + if (this._effect) { + this.stop() + this._effect = undefined + } + this._fill(this.makeRGB(0, 0, 0), _index, _count) + this.update() + } + + blink(r: number, g: number, b: number, interval: number, index?: number, count?: number) { + const _index = index ?? 0 + const _count = count ?? this.length - _index + if (this._blinking) return + this._blinking = true + this._blinkState = false + + const step = () => { + if (!this._blinking) return + this._blinkState = !this._blinkState + if (this._blinkState) { + this._fill(this.makeRGB(r, g, b), _index, _count) + } else { + this._fill(this.makeRGB(0, 0, 0), _index, _count) + } + this.update() + } + this._blinkTimer = Timer.repeat(step, interval) + } + rainbow(index?: number, count?: number) { + const _index = index ?? 0 + const _count = count ?? this.length - _index + if (this._effect) return + + this._effect = new NeoStrand.HueSpan({ strand: this, start: _index, end: _count }) + this.setScheme([this._effect]) + this.start(50) + } +} diff --git a/firmware/stackchan/led/manifest_led.json b/firmware/stackchan/led/manifest_led.json new file mode 100644 index 00000000..670e9ac1 --- /dev/null +++ b/firmware/stackchan/led/manifest_led.json @@ -0,0 +1,27 @@ +{ + "modules": { + "*": ["$(MODULES)/drivers/neostrand/*", "./led"], + "piu/Timeline": "$(MODULES)/piu/All/piuTimeline" + }, + "preload": ["neostrand", "piu/Timeline", "led"], + "platforms": { + "esp32": { + "include": ["$(MODULES)/drivers/neopixel/manifest.json"] + }, + "mac": { + "modules": { + "neopixel": "./neopixel_stub" + } + }, + "lin": { + "modules": { + "neopixel": "./neopixel_stub" + } + }, + "win": { + "modules": { + "neopixel": "./neopixel_stub" + } + } + } +} diff --git a/firmware/stackchan/led/neopixel_stub.ts b/firmware/stackchan/led/neopixel_stub.ts new file mode 100644 index 00000000..6c8d59d6 --- /dev/null +++ b/firmware/stackchan/led/neopixel_stub.ts @@ -0,0 +1,31 @@ +class NeoPixel { + close() {} + update() {} + + makeRGB(_r: number, _g: number, _b: number) { + return 0 + } + makeHSB(_h: number, _s: number, _b: number) { + return 0 + } + + setPixel(_index: number, _color) {} + fill(_color, _index: number, _count: number) {} + getPixel(_index) { + return 0 + } + set brightness(_value) {} + get brightness() { + return 128 + } + + get length() { + return 1 + } + get byteLength() { + return 1 + } +} +Object.freeze(NeoPixel.prototype) + +export default NeoPixel diff --git a/firmware/stackchan/main.ts b/firmware/stackchan/main.ts index e547ac4c..656b02e3 100644 --- a/firmware/stackchan/main.ts +++ b/firmware/stackchan/main.ts @@ -21,6 +21,7 @@ import Microphone from 'microphone' import Tone from 'tone' import { asyncWait } from 'stackchan-util' import loadPreferences from 'loadPreference' +import Led from 'led' // wrapper button class for simulator class SimButton { @@ -112,6 +113,15 @@ function createRobot() { const touch = TouchConstructor ? new Touch(TouchConstructor) : undefined const microphone = Modules.has('embedded:io/audio/in') ? new Microphone() : undefined const tone = new Tone({ volume: ttsPrefs.volume }) + + const configLed = config.led || {} + const led = Object.fromEntries( + Object.entries(configLed).map(([key, config]) => [ + key, + new Led(config as { pin: number; length?: number; order?: string }), + ]), + ) + return new Robot({ driver, renderer, @@ -120,6 +130,7 @@ function createRobot() { touch, tone, microphone, + led, }) } diff --git a/firmware/stackchan/manifest.json b/firmware/stackchan/manifest.json index 9c538a37..3c2d8ed5 100644 --- a/firmware/stackchan/manifest.json +++ b/firmware/stackchan/manifest.json @@ -11,6 +11,7 @@ "./drivers/manifest_driver.json", "./ble/manifest_ble.json", "./dialogues/manifest_dialogue.json", + "./led/manifest_led.json", "./renderers/manifest_renderer.json", "./services/manifest_service.json", "./speeches/manifest_speech.json", diff --git a/firmware/stackchan/robot.ts b/firmware/stackchan/robot.ts index fcb5437a..a7b98b59 100644 --- a/firmware/stackchan/robot.ts +++ b/firmware/stackchan/robot.ts @@ -5,6 +5,7 @@ import type Digital from 'embedded:io/digital' import type Touch from 'touch' import type Microphone from 'microphone' import type Tone from 'tone' +import type Led from 'led' import { createBalloonDecorator } from 'decorator' import { DEFAULT_FONT } from 'consts' import Resource from 'Resource' @@ -67,6 +68,7 @@ type RobotConstructorParam = { touch?: Touch microphone?: Microphone tone?: Tone + led?: Record> } const LEFT_RIGHT = Object.freeze(['left', 'right']) @@ -92,6 +94,7 @@ export class Robot { #touch: Touch #microphone: Microphone #tone: Tone + #led: Record> #isMoving: boolean #renderer: Renderer #paused: boolean @@ -113,6 +116,7 @@ export class Robot { this.#touch = params.touch this.#microphone = params.microphone this.#tone = params.tone + this.#led = params.led ?? {} this.#pose = params.pose ?? { body: { position: { @@ -240,6 +244,15 @@ export class Robot { return this.#microphone } + /** + * get LED + * + * @returns Led instances + */ + get led() { + return this.#led + } + /** * let the robot say things * @@ -479,4 +492,69 @@ export class Robot { } this.updating = false } + + /** + * Turns on an Led with the specified color and optional animation parameters. + * @param ledName - The name identifier of the Led to control + * @param r - Red color value (0-255) + * @param g - Green color value (0-255) + * @param b - Blue color value (0-255) + * @param duration - Optional duration in milliseconds for the animation + * @param index - Optional starting index for the Led animation + * @param count - Optional number of LEDs to animate + */ + lightOn(ledName: string, r: number, g: number, b: number, duration?: number, index?: number, count?: number) { + const led = this.#led[ledName] + if (led) { + led.on(r, g, b, duration, index, count) + } + } + + /** + * Turns off the specified Led. + * + * @param ledName - The name of the Led to turn off. + * @param index - Optional index of the Led to turn off. If not provided, all LEDs of the specified name will be turned off. + * @param count - Optional number of Led to turn off starting from the index. If not provided, all LEDs will be turned off. + * + * @remarks + * This method checks if the Led with the given name exists before attempting to turn it off. + */ + lightOff(ledName: string, index?: number, count?: number) { + const led = this.#led[ledName] + if (led) { + led.off(index, count) + } + } + + /** + * Blinks an Led with the specified color and interval. + * + * @param ledName - The name of the Led to blink. + * @param r - The red component of the color (0-255). + * @param g - The green component of the color (0-255). + * @param b - The blue component of the color (0-255). + * @param interval - The time in milliseconds between blinks. + * @param index - Optional index to specify which Led to control if multiple LEDs are present. + * @param count - Optional number of times to blink the Led. If not provided, it will blink indefinitely. + */ + lightBlink(ledName: string, r: number, g: number, b: number, interval: number, index?: number, count?: number) { + const led = this.#led[ledName] + if (led) { + led.blink(r, g, b, interval, index, count) + } + } + + /** + * Displays a rainbow light effect on the specified Led. + * @param ledName - The name of the Led to apply the rainbow effect to. + * @param index - Optional starting index for the rainbow effect. + * @param count - Optional number of Leds to apply the rainbow effect to. + */ + lightRainbow(ledName: string, index?: number, count?: number) { + const led = this.#led[ledName] + if (led) { + led.rainbow(index, count) + } + } } diff --git a/firmware/stackchan/utilities/manifest_utility.json b/firmware/stackchan/utilities/manifest_utility.json index d1d7dcc3..ee77180c 100644 --- a/firmware/stackchan/utilities/manifest_utility.json +++ b/firmware/stackchan/utilities/manifest_utility.json @@ -1,6 +1,7 @@ { "include": [ "$(MODDABLE)/examples/manifest_base.json", + "$(MODDABLE)/examples/manifest_net.json", "$(MODULES)/files/preference/manifest.json", "$(MODULES)/base/structuredClone/manifest.json", "$(MODULES)/base/deepEqual/manifest.json", diff --git a/firmware/tests/led/main.ts b/firmware/tests/led/main.ts new file mode 100644 index 00000000..b72f783f --- /dev/null +++ b/firmware/tests/led/main.ts @@ -0,0 +1,25 @@ +import Led from 'led' +import { asyncWait } from 'stackchan-util' + +// M5Stack + M5Go bottom +const ledConfig = { pin: 15, length: 10 } +const led = new Led(ledConfig) + +led.on(255, 0, 0) +await asyncWait(500) +led.on(0, 255, 0) +await asyncWait(500) +led.on(0, 0, 255) +await asyncWait(500) +led.off() +await asyncWait(1000) + +led.on(255, 255, 255, 1000) +await asyncWait(1000) + +led.blink(255, 255, 0, 500) +await asyncWait(5000) +led.off() +await asyncWait(1000) + +led.rainbow() diff --git a/firmware/tests/led/manifest.json b/firmware/tests/led/manifest.json new file mode 100644 index 00000000..a5dc8f1d --- /dev/null +++ b/firmware/tests/led/manifest.json @@ -0,0 +1,16 @@ +{ + "include": [ + "$(MODDABLE)/examples/manifest_base.json", + "$(MODDABLE)/examples/manifest_typings.json", + "../../stackchan/utilities/manifest_utility.json", + "../../stackchan/led/manifest_led.json" + ], + "modules": { + "*": ["./main"] + }, + "defines": { + "main": { + "async": 1 + } + } +} diff --git a/firmware/tsconfig.json b/firmware/tsconfig.json index 0656b91b..8a9a72b4 100644 --- a/firmware/tsconfig.json +++ b/firmware/tsconfig.json @@ -21,6 +21,7 @@ "./stackchan/default-mods/*", "./stackchan/dialogues/*", "./stackchan/drivers/*", + "./stackchan/led/*", "./stackchan/renderers/*", "./stackchan/services/*", "./stackchan/speeches/*", From 416eb5920795d6fe7ce2f329c3741b2c13aa1d9c Mon Sep 17 00:00:00 2001 From: Satoshi Tanaka Date: Sun, 28 Dec 2025 12:04:25 +0900 Subject: [PATCH 2/5] fix comment Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- firmware/stackchan/robot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firmware/stackchan/robot.ts b/firmware/stackchan/robot.ts index a7b98b59..78bef675 100644 --- a/firmware/stackchan/robot.ts +++ b/firmware/stackchan/robot.ts @@ -536,7 +536,7 @@ export class Robot { * @param b - The blue component of the color (0-255). * @param interval - The time in milliseconds between blinks. * @param index - Optional index to specify which Led to control if multiple LEDs are present. - * @param count - Optional number of times to blink the Led. If not provided, it will blink indefinitely. + * @param count - Optional number of LEDs to blink. If not provided, it will affect all LEDs from the index to the end. */ lightBlink(ledName: string, r: number, g: number, b: number, interval: number, index?: number, count?: number) { const led = this.#led[ledName] From 91021afc15cb9592e0e310595e973d2f8121f924 Mon Sep 17 00:00:00 2001 From: STC Date: Sun, 28 Dec 2025 20:17:19 +0900 Subject: [PATCH 3/5] refactor blink as NeoStrandEffect --- firmware/stackchan/led/led.ts | 93 +++++++++++++++++++++++------------ firmware/stackchan/robot.ts | 6 +-- 2 files changed, 65 insertions(+), 34 deletions(-) diff --git a/firmware/stackchan/led/led.ts b/firmware/stackchan/led/led.ts index 04ead4a7..22980734 100644 --- a/firmware/stackchan/led/led.ts +++ b/firmware/stackchan/led/led.ts @@ -1,4 +1,4 @@ -import { NeoStrand, type NeoStrandEffect } from 'neostrand' +import { NeoStrand, NeoStrandEffect, type NeoStrandEffectDictionary } from 'neostrand' import Timer from 'timer' const Timing_WS2812B = { @@ -7,10 +7,50 @@ const Timing_WS2812B = { reset: { level0: 0, duration0: 100, level1: 0, duration1: 100 }, } as const +class Blink extends NeoStrandEffect { + private rgbOn: number + private rgbOff: number + constructor( + dictionary: NeoStrandEffectDictionary & { + rgb: { r: number; g: number; b: number } + index?: number + count?: number + duration?: number + }, + ) { + super(dictionary) + this.name = 'Blink' + this.loop = 1 + + if (dictionary.index) { + this.start = dictionary.index + } + if (dictionary.count) { + this.size = dictionary.count + this.end = this.start + this.size + if (this.end > this.strand.length) this.end = this.strand.length + } + this.dur = dictionary.duration ?? 1000 + this.rgbOn = this.strand.makeRGB(dictionary.rgb.r, dictionary.rgb.g, dictionary.rgb.b) + this.rgbOff = this.strand.makeRGB(0, 0, 0) + } + + activate(effect: NeoStrandEffect): void { + effect.timeline.on(effect, { effectValue: [0, effect.dur] }, effect.dur, null, 0) + effect.reset(effect) + } + + set effectValue(value) { + const half = this.dur / 2 + const newColor = value < half ? this.rgbOn : this.rgbOff + const currentColor = this.strand.getPixel(this.start) + if (newColor !== currentColor) { + for (let i = this.start; i < this.end; i++) this.strand.set(i, newColor, this.start, this.end) + } + } +} + export default class Led extends NeoStrand { - private _blinkTimer?: Timer - private _blinking: boolean = false - private _blinkState: boolean = false private _effect?: NeoStrandEffect constructor(parameters: { @@ -31,58 +71,49 @@ export default class Led extends NeoStrand { } } + private _stopEffect() { + if (this._effect) { + this.stop() + this._effect = undefined + } + } + on(r: number, g: number, b: number, duration?: number, index?: number, count?: number) { const _index = index ?? 0 const _count = count ?? this.length - _index + this._stopEffect() this._fill(this.makeRGB(r, g, b), _index, _count) this.update() if (duration) { Timer.set(() => { - this._fill(this.makeRGB(0, 0, 0), _index, _count) - this.update() + this.off(_index, _count) }, duration) } } + off(index?: number, count?: number) { const _index = index ?? 0 const _count = count ?? this.length - _index - this._blinking = false - if (this._blinkTimer !== undefined) { - Timer.clear(this._blinkTimer) - this._blinkTimer = undefined - } - if (this._effect) { - this.stop() - this._effect = undefined - } + this._stopEffect() this._fill(this.makeRGB(0, 0, 0), _index, _count) this.update() } - blink(r: number, g: number, b: number, interval: number, index?: number, count?: number) { + blink(r: number, g: number, b: number, duration: number, index?: number, count?: number) { const _index = index ?? 0 const _count = count ?? this.length - _index - if (this._blinking) return - this._blinking = true - this._blinkState = false + this._stopEffect() - const step = () => { - if (!this._blinking) return - this._blinkState = !this._blinkState - if (this._blinkState) { - this._fill(this.makeRGB(r, g, b), _index, _count) - } else { - this._fill(this.makeRGB(0, 0, 0), _index, _count) - } - this.update() - } - this._blinkTimer = Timer.repeat(step, interval) + this._effect = new Blink({ strand: this, rgb: { r, g, b }, index: _index, count: _count, duration: duration }) + this.setScheme([this._effect]) + this.start(50) } + rainbow(index?: number, count?: number) { const _index = index ?? 0 const _count = count ?? this.length - _index - if (this._effect) return + this._stopEffect() this._effect = new NeoStrand.HueSpan({ strand: this, start: _index, end: _count }) this.setScheme([this._effect]) diff --git a/firmware/stackchan/robot.ts b/firmware/stackchan/robot.ts index a7b98b59..52000d5b 100644 --- a/firmware/stackchan/robot.ts +++ b/firmware/stackchan/robot.ts @@ -534,14 +534,14 @@ export class Robot { * @param r - The red component of the color (0-255). * @param g - The green component of the color (0-255). * @param b - The blue component of the color (0-255). - * @param interval - The time in milliseconds between blinks. + * @param duration - The time in milliseconds between blinks. * @param index - Optional index to specify which Led to control if multiple LEDs are present. * @param count - Optional number of times to blink the Led. If not provided, it will blink indefinitely. */ - lightBlink(ledName: string, r: number, g: number, b: number, interval: number, index?: number, count?: number) { + lightBlink(ledName: string, r: number, g: number, b: number, duration: number, index?: number, count?: number) { const led = this.#led[ledName] if (led) { - led.blink(r, g, b, interval, index, count) + led.blink(r, g, b, duration, index, count) } } From 3ad21a50906d70ab19463254d3b3660b9f4ca814 Mon Sep 17 00:00:00 2001 From: Satoshi Tanaka Date: Sun, 28 Dec 2025 21:37:52 +0900 Subject: [PATCH 4/5] Update firmware/stackchan/led/led.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- firmware/stackchan/led/led.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firmware/stackchan/led/led.ts b/firmware/stackchan/led/led.ts index 22980734..99755881 100644 --- a/firmware/stackchan/led/led.ts +++ b/firmware/stackchan/led/led.ts @@ -115,7 +115,7 @@ export default class Led extends NeoStrand { const _count = count ?? this.length - _index this._stopEffect() - this._effect = new NeoStrand.HueSpan({ strand: this, start: _index, end: _count }) + this._effect = new NeoStrand.HueSpan({ strand: this, start: _index, end: _index + _count }) this.setScheme([this._effect]) this.start(50) } From f680f5c7f17efd47c9fa49ac16189271dee216c2 Mon Sep 17 00:00:00 2001 From: Satoshi Tanaka Date: Sun, 28 Dec 2025 21:38:56 +0900 Subject: [PATCH 5/5] Update firmware/stackchan/led/neopixel_stub.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- firmware/stackchan/led/neopixel_stub.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/firmware/stackchan/led/neopixel_stub.ts b/firmware/stackchan/led/neopixel_stub.ts index 6c8d59d6..2582ddca 100644 --- a/firmware/stackchan/led/neopixel_stub.ts +++ b/firmware/stackchan/led/neopixel_stub.ts @@ -9,12 +9,12 @@ class NeoPixel { return 0 } - setPixel(_index: number, _color) {} - fill(_color, _index: number, _count: number) {} - getPixel(_index) { + setPixel(_index: number, _color: number) {} + fill(_color: number, _index: number, _count: number) {} + getPixel(_index: number) { return 0 } - set brightness(_value) {} + set brightness(_value: number) {} get brightness() { return 128 }