diff --git a/firmware/stackchan/led/led.ts b/firmware/stackchan/led/led.ts new file mode 100644 index 00000000..99755881 --- /dev/null +++ b/firmware/stackchan/led/led.ts @@ -0,0 +1,122 @@ +import { NeoStrand, NeoStrandEffect, type NeoStrandEffectDictionary } 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 + +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 _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) + } + } + + 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.off(_index, _count) + }, duration) + } + } + + off(index?: number, count?: number) { + const _index = index ?? 0 + const _count = count ?? this.length - _index + this._stopEffect() + this._fill(this.makeRGB(0, 0, 0), _index, _count) + this.update() + } + + blink(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._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 + this._stopEffect() + + this._effect = new NeoStrand.HueSpan({ strand: this, start: _index, end: _index + _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..2582ddca --- /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: number) {} + fill(_color: number, _index: number, _count: number) {} + getPixel(_index: number) { + return 0 + } + set brightness(_value: number) {} + 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..ea7e94ca 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 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 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, duration: number, index?: number, count?: number) { + const led = this.#led[ledName] + if (led) { + led.blink(r, g, b, duration, 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/*",