blinkstick

package module
v0.6.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2026 License: MIT Imports: 13 Imported by: 0

README

go-blinkstick

Go Reference codecov OpenSSF Scorecard

A Go package for driving BlinkStick USB LED devices.

This package is unofficial. It is not made by, or affiliated with, Agile Innovative, the makers of BlinkStick.

Supported devices

Device macOS Linux
BlinkStick Nano yes yes
BlinkStick Square yes yes

See CAPABILITIES.md for every feature these devices have and what this package supports so far.

Requirements

  • Go 1.26 or later.
  • cgo. Builds with CGO_ENABLED=0 will not work.
  • On macOS, install the Xcode command line tools with xcode-select --install.
  • On Linux, install libudev development headers (libudev-dev on Debian/Ubuntu, systemd-libs on Arch, libudev-devel on Fedora) to build, and see Linux below for the udev rule needed to open a stick without root.
  • Nothing else. hidapi is bundled.

Install

go get github.com/bawdo/go-blinkstick

Quick start

package main

import (
	"context"
	"log"
	"time"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	d, err := blinkstick.Open()
	if err != nil {
		log.Fatal(err)
	}
	defer d.Close()

	red, _ := blinkstick.ParseRGB("#ff0000")
	if err := d.SetAll(red); err != nil {
		log.Fatal(err)
	}

	// Pulse green twice, one second each.
	err = d.Pulse(context.Background(), blinkstick.RGB{G: 255}, time.Second, 2)
	if err != nil {
		log.Fatal(err)
	}
}

More than one stick

List returns every attached stick. Open a particular one by its serial:

infos, err := blinkstick.List()
if err != nil {
	log.Fatal(err)
}
for _, info := range infos {
	fmt.Println(info.Serial, info.Model.Name)
}

d, err := blinkstick.OpenSerial("BS072777-3.0")
By name

Give a stick a name once, then open it by that name from then on:

d.SetName("desk") // stored on the stick, survives unplugging

d, err := blinkstick.OpenName("desk")

ListNamed is List with each stick's name added. Both ListNamed and OpenName open every attached stick to read its name, so a stick another program holds, or one whose name cannot be read, shows up in ListNamed with Busy set, and OpenName skips it. Names are matched exactly, and OpenName returns ErrDuplicateName if two sticks share one. Names are bytes stored on the stick, possibly by another tool, so sanitise them before printing them to a terminal.

Each open Device is independent, and its methods are safe to call from several goroutines. macOS lets only one process open a stick at a time; Linux does not, so Busy in ListNamed will rarely be true there — another process holding a stick open does not stop it being opened again.

Good to know

  • Blink, Pulse and Morph block until they finish. Cancel them with the context.
  • If a stick is unplugged, the next call that fails reopens it by serial and repaints the last colours written. If it is still missing you get ErrDisconnected, and the next call tries again. Brightness limit and inverse settings carry over.
  • SetBrightnessLimit caps how bright the LEDs get. A Square at full white draws about 500 mA.
  • SetInfoBlock, SetName and SetMode write to EEPROM on the device, which wears out with heavy use. Do not call them in a loop.

Linux

BlinkStick devices are owned by root by default, so opening one needs a udev rule. Install the one in this repo:

sudo cp udev/60-blinkstick.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger

Then unplug and replug the stick (or reboot). This grants access to whoever is logged in at the desktop (via systemd-logind's uaccess), not a udev group, so no group membership or new login session is needed beyond replugging the device.

The rule only grants the hidraw node, which is all this package uses. Before v0.6.0 it also granted the raw USB device. If you use a tool that talks to the stick through libusb (such as the official Python blinkstick package), add this line to the rule as well:

SUBSYSTEM=="usb", ATTR{idVendor}=="20a0", ATTR{idProduct}=="41e5", TAG+="uaccess"

Development

make help           # list targets
make test           # vet and unit tests, no hardware needed
make fuzz           # each fuzz target for 15s (FUZZTIME=5m to go longer)
make build          # compile
make test-hardware  # tests against a real Nano and Square
make test-reconnect # you unplug and replug the Nano when told

Automated tests must never write EEPROM on a real stick. EEPROM wears out, and a test suite runs far more often than anyone renames a stick. Unit tests write info blocks and names only to an in-memory fake. Hardware tests may read names and info blocks, and write LEDs (RAM, no wear), but never call SetInfoBlock, SetName, SetMode or anything else that writes EEPROM. To cover OpenName on hardware, set a name by hand once.

When checking EEPROM by hand, note that a stick refuses every transfer for about 12 to 15 ms after a mode write (report 4). SetMode waits that out (at most 250 ms) before it returns. A script that sends report 4 some other way must pause before its next transfer, or that transfer fails as if the stick had been unplugged.

Versioning

Releases follow semantic versioning and are tagged vX.Y.Z. Until v1.0.0 the API may change between minor releases. See CHANGELOG.md.

To release, give the version a dated section in CHANGELOG.md, merge it, and push a signed tag on main once CI passes. The release workflow creates the GitHub release from that section. Go has no registry to upload to: the module proxy fetches the tag and keeps it for good, so a tag cannot be taken back once pushed, only retracted in a later release.

Licence

MIT. See LICENSE.

Hardware test limitations

make test-hardware and make test-reconnect need both a Nano and a Square attached. With one stick, or without a Nano, they fail straight away.

TODO
  • Open every supported stick attached, and skip rather than fail when there are none.
  • Skip the multi-device test with fewer than two sticks, and the report 10 probe without a Square.
  • Let the reconnect test use any stick, chosen with BLINKSTICK_SERIAL, and name it in the prompts.
  • Say in Development what each target needs.

Documentation

Overview

Package blinkstick drives BlinkStick USB LED devices. The Nano and Square are supported on macOS and Linux.

Open a stick, set its LEDs and close it:

d, err := blinkstick.Open()
if err != nil {
	log.Fatal(err)
}
defer d.Close()
err = d.SetAll(blinkstick.RGB{R: 255})

Use List to find every attached stick and OpenSerial to open a particular one. A stick can also be given a name with Device.SetName and opened by it with OpenName. Several sticks can be open at once, and a Device is safe for concurrent use.

Requirements

The package uses cgo and a bundled copy of hidapi. On macOS, install the Xcode command line tools; macOS lets only one process open a stick at a time. On Linux, install libudev's development headers, and see the udev rule under "Linux" in the README for opening a stick without root.

EEPROM

Device.SetInfoBlock, Device.SetName and Device.SetMode write to EEPROM on the device, which wears with use. Setting LEDs does not.

This package is unofficial and not affiliated with Agile Innovative.

Example
package main

import (
	"log"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	d, err := blinkstick.Open()
	if err != nil {
		log.Fatal(err)
	}
	defer d.Close()

	if err := d.SetAll(blinkstick.RGB{R: 255}); err != nil {
		log.Fatal(err)
	}
}

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	Off   = RGB{}
	White = RGB{R: 255, G: 255, B: 255}
)

Common values.

View Source
var (
	ErrNotFound    = errors.New("blinkstick: device not found")
	ErrUnsupported = errors.New("blinkstick: unsupported model")
	ErrOutOfRange  = errors.New("blinkstick: out of range")
	ErrClosed      = errors.New("blinkstick: device closed")

	ErrUnsupportedMode = errors.New("blinkstick: mode not supported by this model")
	ErrInvalidName     = errors.New("blinkstick: invalid name")
	ErrDuplicateName   = errors.New("blinkstick: name used by more than one stick")
	ErrDisconnected    = error(&disconnectedError{})
)

Errors returned by this package. Check them with errors.Is. ErrNotFound is also returned when a stick is attached but cannot be opened, for example because another process holds it (macOS opens devices exclusively; Linux does not, so this does not happen there). ErrDisconnected means an open stick was unplugged and could not be reopened; errors.Is also matches it to ErrNotFound.

View Source
var (
	Nano   = Model{Name: "Nano", LEDs: 2, /* contains filtered or unexported fields */}
	Square = Model{Name: "Square", LEDs: 8, /* contains filtered or unexported fields */}
)

Supported models.

Functions

func ColourNames added in v0.3.0

func ColourNames() []string

ColourNames returns every CSS colour name that ParseRGB accepts, lower case and sorted. Each call returns a new slice, so callers may modify it. ParseRGB gives the value of a name.

Example
package main

import (
	"fmt"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	// Offer names for shell completion, each described by its value.
	for _, name := range blinkstick.ColourNames()[:3] {
		c, _ := blinkstick.ParseRGB(name)
		fmt.Printf("%s\t%s\n", name, c.Hex())
	}
}
Output:
aliceblue	#f0f8ff
antiquewhite	#faebd7
aqua	#00ffff

Types

type Device

type Device struct {
	// contains filtered or unexported fields
}

Device is an open BlinkStick. Its methods are safe for concurrent use, but two effects running at once on one Device interleave their frames.

If the stick is unplugged, the next call that fails reopens it by serial, repaints the last frame written (a replugged stick comes back dark) and carries on. If the stick is still missing it returns ErrDisconnected, and the call after that tries again.

func Open

func Open() (*Device, error)

Open opens the first BlinkStick found. If the first stick found is a model this package does not support, Open returns ErrUnsupported even when a supported stick is also attached; use List and OpenSerial to choose one.

func OpenName added in v0.2.0

func OpenName(name string) (*Device, error)

OpenName opens the BlinkStick whose stored name is exactly name. It opens each attached stick in turn to read its name, and skips sticks it cannot open or read, including any this process already has open; if none matches, the error says why each was skipped. It returns ErrDuplicateName if more than one stick has the name.

Example
package main

import (
	"log"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	d, err := blinkstick.OpenName("desk")
	if err != nil {
		log.Fatal(err)
	}
	defer d.Close()
	d.SetAll(blinkstick.RGB{G: 255})
}

func OpenSerial

func OpenSerial(serial string) (*Device, error)

OpenSerial opens the BlinkStick with the given serial, as reported by List.

Example
package main

import (
	"fmt"
	"log"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	d, err := blinkstick.OpenSerial("BS072777-3.0")
	if err != nil {
		log.Fatal(err)
	}
	defer d.Close()
	fmt.Println(d.Info().Model.Name)
}
func (d *Device) Blink(ctx context.Context, c RGB, period time.Duration, repeats int) error

Blink turns every LED to c for period/2, then off for period/2, repeats times. It ends with the LEDs off. If ctx is cancelled the LEDs are left as they are.

func (*Device) Close

func (d *Device) Close() error

Close releases the device. Using a Device after Close returns ErrClosed.

func (*Device) Frame

func (d *Device) Frame() ([]RGB, error)

Frame reads every LED back from the device. Values are those stored on the device, so they reflect any brightness limit in force when they were written. With SetInverse on they are flipped back.

func (*Device) Info

func (d *Device) Info() Info

Info returns the identity of the device.

func (*Device) InfoBlock

func (d *Device) InfoBlock(n int) ([]byte, error)

InfoBlock reads user data block n (1 or 2) from the device's EEPROM, with trailing NUL bytes removed. Because trailing NUL bytes are trimmed, data that ends in NUL bytes does not round-trip. The bytes are whatever was last written, by this package or another tool, so treat them as untrusted.

func (*Device) LED

func (d *Device) LED(i int) (RGB, error)

LED reads LED i back from the device, as Frame does.

func (*Device) Mode added in v0.5.0

func (d *Device) Mode() (Mode, error)

Mode reads the device's current mode. It returns ModeRGB and an error if the read fails.

func (*Device) Morph

func (d *Device) Morph(ctx context.Context, to RGB, duration time.Duration) error

Morph fades every LED from its current value to `to` over duration, in 20 ms steps. A duration under one step jumps straight to `to`. It ends with every LED at `to`. If ctx is cancelled the LEDs are left as they are.

func (*Device) Name added in v0.2.0

func (d *Device) Name() (string, error)

Name returns the name stored on the stick, or "" if none is set. Another tool may have stored bytes that are not valid UTF-8 or that include control characters, so sanitise the name before printing it to a terminal.

func (*Device) Off

func (d *Device) Off() error

Off turns every LED off.

func (*Device) Pulse

func (d *Device) Pulse(ctx context.Context, c RGB, duration time.Duration, repeats int) error

Pulse fades every LED from off up to c over duration/2 and back down over duration/2, repeats times. It ends with the LEDs off. If ctx is cancelled the LEDs are left as they are.

Example
package main

import (
	"context"
	"log"
	"time"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	d, err := blinkstick.Open()
	if err != nil {
		log.Fatal(err)
	}
	defer d.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := d.Pulse(ctx, blinkstick.RGB{G: 255}, time.Second, 3); err != nil {
		log.Fatal(err)
	}
}

func (*Device) SetAll

func (d *Device) SetAll(c RGB) error

SetAll sets every LED to c.

func (*Device) SetBrightnessLimit

func (d *Device) SetBrightnessLimit(limit uint8)

SetBrightnessLimit caps every channel written from now on, scaling each as v * limit / 255. 255, the default, means no limit. Frame and LED return the scaled values stored on the device.

Example
package main

import (
	"log"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	d, err := blinkstick.Open()
	if err != nil {
		log.Fatal(err)
	}
	defer d.Close()

	d.SetBrightnessLimit(64) // about a quarter of full brightness
	d.SetAll(blinkstick.White)
}

func (*Device) SetFrame

func (d *Device) SetFrame(leds []RGB) error

SetFrame sets every LED at once. len(leds) must equal Info().Model.LEDs.

func (*Device) SetInfoBlock

func (d *Device) SetInfoBlock(n int, data []byte) error

SetInfoBlock writes up to 32 bytes to user data block n (1 or 2), padded with zeros. It writes EEPROM, which wears with use, so do not call it in a loop.

func (*Device) SetInverse added in v0.2.0

func (d *Device) SetInverse(on bool)

SetInverse flips every channel written from now on to 255 - v, after any brightness limit, for LEDs wired so that 255 means off. Frame and LED flip values back, so they return what was written. It does not repaint the LEDs, so call it before setting them.

func (*Device) SetLED

func (d *Device) SetLED(i int, c RGB) error

SetLED sets LED i to c and leaves the others as they are. It sends one report (report 5), which takes about a millisecond. The first call on a Device that has written nothing also reads the frame back once, so a reconnect can repaint the other LEDs.

Example
package main

import (
	"log"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	d, err := blinkstick.Open()
	if err != nil {
		log.Fatal(err)
	}
	defer d.Close()

	// Top LED red, bottom LED blue on a Nano.
	d.SetLED(0, blinkstick.RGB{R: 255})
	d.SetLED(1, blinkstick.RGB{B: 255})
}

func (*Device) SetMode added in v0.5.0

func (d *Device) SetMode(m Mode) error

SetMode switches the device to mode m. It returns ErrUnsupportedMode if the model does not allow m, which covers modes 0 and 1 on every model here.

SetMode writes EEPROM, which wears with use, so call it once and not in a loop. It skips the write when the stick is already in m. The change takes effect at once, with no replug. The stick stops answering for a short time after the write, so SetMode waits until it reads back m, typically 12 to 15 ms. If the stick has not answered after 250 ms it returns ErrDisconnected.

Mode 3 makes report 1 from other software colour every LED. This package's LED methods work the same in modes 2 and 3.

func (*Device) SetName added in v0.2.0

func (d *Device) SetName(name string) error

SetName stores name on the stick, replacing any name already there. name is UTF-8 of at most 32 bytes with no NUL characters; "" clears the name. Names are not checked for uniqueness, but OpenName fails if two attached sticks share one. It writes EEPROM, which wears with use, so do not call it in a loop.

type Info

type Info struct {
	Serial       string // for example "BS072777-3.0"
	Version      string // firmware version from the serial, for example "3.0"
	Manufacturer string
	Product      string
	Model        Model
}

Info identifies an attached BlinkStick.

func List

func List() ([]Info, error)

List returns every attached BlinkStick, including models this package does not support yet (their Model.Name is "unknown").

Example
package main

import (
	"fmt"
	"log"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	infos, err := blinkstick.List()
	if err != nil {
		log.Fatal(err)
	}
	for _, info := range infos {
		fmt.Println(info.Serial, info.Model.Name, info.Model.LEDs)
	}
}

type Mode added in v0.5.0

type Mode uint8

Mode is the device's LED output mode, stored in EEPROM and read with report 4.

const (
	// ModeRGB is normal mode for the BlinkStick Pro RGB outputs.
	ModeRGB Mode = 0
	// ModeRGBInverse is inverse mode for the BlinkStick Pro RGB outputs.
	ModeRGBInverse Mode = 1
	// ModeWS2812 drives the WS2812 LEDs, which is what every LED method here needs.
	ModeWS2812 Mode = 2
	// ModeWS2812Mirror is ModeWS2812 where report 1 colours every LED, as sent by other BlinkStick software.
	ModeWS2812Mirror Mode = 3
)

func (Mode) String added in v0.5.0

func (m Mode) String() string

String returns the mode's name, or "mode(n)" for a value with no name.

type Model

type Model struct {
	Name string // "Nano", "Square" or "unknown"
	LEDs int    // addressable LEDs on channel 0
	// contains filtered or unexported fields
}

Model describes a BlinkStick product. Models differ only in data, including which modes they allow, so supporting another product means adding a row to the models table.

func (Model) SupportsMode added in v0.5.0

func (m Model) SupportsMode(x Mode) bool

SupportsMode reports whether SetMode accepts x on this model.

type NamedInfo added in v0.2.0

type NamedInfo struct {
	Info
	Name string // "" if none is set, or if Busy
	Busy bool   // the stick could not be opened or its name read, for example because another process holds it
}

NamedInfo is an attached stick and the name stored on it.

func ListNamed added in v0.2.0

func ListNamed() ([]NamedInfo, error)

ListNamed returns every attached BlinkStick with its name. It opens each stick in turn to read the name and closes it again, so it is slower than List. Sticks it cannot open or read, including any this process already has open, are listed with Busy set.

Example
package main

import (
	"fmt"
	"log"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	named, err := blinkstick.ListNamed()
	if err != nil {
		log.Fatal(err)
	}
	for _, n := range named {
		fmt.Printf("%s %s %q busy=%v\n", n.Serial, n.Model.Name, n.Name, n.Busy)
	}
}

type RGB

type RGB struct{ R, G, B uint8 }

RGB is a colour with 8 bits per channel.

func ParseRGB

func ParseRGB(s string) (RGB, error)

ParseRGB parses a colour in any of these forms:

  • hex "#rgb" or "#rrggbb", with or without the "#"
  • "r,g,b" with decimal channels from 0 to 255
  • a CSS colour name such as "cornflowerblue", in any case
Example
package main

import (
	"fmt"
	"log"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	c, err := blinkstick.ParseRGB("#ff8800")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(c)
}
Output:
{255 136 0}

func RandomRGB added in v0.2.0

func RandomRGB() RGB

RandomRGB returns a colour with each channel picked at random from 0 to 255. Many results are muted or greyish; use RandomVivid for bright ones. It is not suitable for anything security related.

func RandomVivid added in v0.2.0

func RandomVivid() RGB

RandomVivid returns a fully saturated colour at full brightness with a random hue: one channel is always 255 and another is always 0.

Example
package main

import (
	"log"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	d, err := blinkstick.Open()
	if err != nil {
		log.Fatal(err)
	}
	defer d.Close()

	d.SetAll(blinkstick.RandomVivid())
}

func (RGB) Hex added in v0.3.0

func (c RGB) Hex() string

Hex returns c as lower case "#rrggbb".

Example
package main

import (
	"fmt"
	"log"

	"github.com/bawdo/go-blinkstick"
)

func main() {
	c, err := blinkstick.ParseRGB("cornflowerblue")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(c.Hex())
	fmt.Println(blinkstick.RGB{R: 1}.Hex())
}
Output:
#6495ed
#010000

func (RGB) Inverse added in v0.2.0

func (c RGB) Inverse() RGB

Inverse returns c with each channel flipped: 255 - v.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL