Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions firmware/stackchan/led/led.ts
Original file line number Diff line number Diff line change
@@ -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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation for setting pixel colors within the Blink effect is inefficient and contains a bug. The for loop calls this.strand.set() on each iteration, which in neostrand triggers an update of the LED strand. This leads to multiple, unnecessary updates within a single animation frame. Furthermore, the last argument to set() is this.end (an index), where a count is expected.

A more efficient and correct approach is to use the fill() method to update the pixel buffer for the entire segment at once. The animation timeline will then handle calling update() once per frame.

Suggested change
for (let i = this.start; i < this.end; i++) this.strand.set(i, newColor, this.start, this.end)
this.strand.fill(newColor, this.start, this.size)

}
}
}

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)
}
}
27 changes: 27 additions & 0 deletions firmware/stackchan/led/manifest_led.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
31 changes: 31 additions & 0 deletions firmware/stackchan/led/neopixel_stub.ts
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions firmware/stackchan/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a type assertion with as bypasses type-checking and can hide potential bugs if the config object from preferences doesn't match the expected shape. This could lead to runtime errors. For better type safety, consider adding a type guard function to validate the config object's structure before creating a new Led instance. This would make your code more robust against invalid configurations.

Copy link
Owner Author

@stc1988 stc1988 Dec 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gemini-code-assist 例えばどんな対策をしたらいい?

]),
)

return new Robot({
driver,
renderer,
Expand All @@ -120,6 +130,7 @@ function createRobot() {
touch,
tone,
microphone,
led,
})
}

Expand Down
1 change: 1 addition & 0 deletions firmware/stackchan/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions firmware/stackchan/robot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -67,6 +68,7 @@ type RobotConstructorParam<T extends string> = {
touch?: Touch
microphone?: Microphone
tone?: Tone
led?: Record<string, InstanceType<typeof Led>>
}

const LEFT_RIGHT = Object.freeze(['left', 'right'])
Expand All @@ -92,6 +94,7 @@ export class Robot {
#touch: Touch
#microphone: Microphone
#tone: Tone
#led: Record<string, InstanceType<typeof Led>>
#isMoving: boolean
#renderer: Renderer
#paused: boolean
Expand All @@ -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: {
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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)
}
}
}
1 change: 1 addition & 0 deletions firmware/stackchan/utilities/manifest_utility.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
25 changes: 25 additions & 0 deletions firmware/tests/led/main.ts
Original file line number Diff line number Diff line change
@@ -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()
16 changes: 16 additions & 0 deletions firmware/tests/led/manifest.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
1 change: 1 addition & 0 deletions firmware/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"./stackchan/default-mods/*",
"./stackchan/dialogues/*",
"./stackchan/drivers/*",
"./stackchan/led/*",
"./stackchan/renderers/*",
"./stackchan/services/*",
"./stackchan/speeches/*",
Expand Down
Loading