Forráskód Böngészése

write a definition for a board that has none, with the names left open

`keyboard fetch` gets a file where VIA carries one. It does not where
VIA does not: 947 of the 2029 definitions name one of VIA's built-in
menus rather than listing effects, and 161 carry a list at all. A board
in the first group and a board outside the collection both come back
with `0 effects` and nothing to act on.

The effect *slots* are answerable, and the firmware answers. The new
command asks each channel for its highest effect ID and writes one
unnamed option per slot, so the file states what exists and the naming
is the user's. A name the tool wrote would be indistinguishable from
the manufacturer's, because nothing in a file can be read back off a
keyboard to check it. So the generated definition names no effect and
the channel reports none until a name is filled in, which is the same
honest state the board was in before.

The names other keyboards use travel beside the file rather than in it,
in a `.spotted.txt` note. JSON cannot hold them: the tool would have to
read comments and VIA's parser would reject them. Every name comes
with the number of boards that wrote it and the manufacturer behind most
of them, which is what stops a name being a guess wearing a count. The
measurement says why: of the 122 definitions listing an rgb_matrix
effect, 95 are one manufacturer's, and where definitions disagree about
an ID they disagree about which effect it is rather than how to spell
it — 7 is rainbow_moving_chevron on 95 boards and cycle_out_in on 8.

`internal/spotted/spotted.json` is generated by
`internal/spotted/generate` from a checkout of VIA's collection and
carries the commit it was measured from. It is a snapshot of a
collection that moves, so the commit is what says how far out of date.

An empty option name is now skipped by the parser, so a slot without a
name states a slot and not an effect called "".
Paul-Dieter Klumpp 1 hete
szülő
commit
a9ce27f407

+ 282 - 0
cmd/qmk-rgb-tool/definition_generate.go

@@ -0,0 +1,282 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/spf13/cobra"
+	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
+	"netdome.biz/paul/qmk-rgb/internal/spotted"
+	"netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+// effectRangeProbe is what `keyboard definitions generate` needs from a
+// keyboard: the highest effect ID each channel takes, and a way to close it.
+// It is deliberately not a method on rgbProtocol, because that interface is what
+// every other command's test stub implements and a probe is not something
+// brightness and colour have to know about.
+type effectRangeProbe interface {
+	EffectTop(via.Channel) (int, error)
+	Close() error
+}
+
+// generateForce replaces a definition that is already stored. It is the same
+// rule `keyboard fetch` follows, for the same reason: the file may be one the
+// user has written themselves, and this command is the one they would have run
+// to write it.
+var generateForce bool
+
+// The value IDs a VIA lighting menu addresses, from quantum/via.h. They are the
+// same bytes on every lighting subsystem, which is why one scaffold covers every
+// channel.
+const (
+	viaValueBrightness = 0x01
+	viaValueEffect     = 0x02
+	viaValueSpeed      = 0x03
+)
+
+// NewKeyboardDefinitionsGenerateCmd is `keyboard definitions generate`. It writes
+// a definition for a board that has none, with the effect slots the keyboard
+// reports and no names in them, plus a note beside it saying which spellings
+// other keyboards' definitions use for the same numbers.
+//
+// The names are the user's to write, and the command does not write them into
+// the definition: a name it put there would read as the manufacturer's own, and
+// nothing in a file can be read back off a keyboard to check it. An option with
+// an empty name is a slot without a name, so the channel reports no effects
+// until one is filled in — which is the honest state, and the same one a board
+// with no definition reports.
+func NewKeyboardDefinitionsGenerateCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "generate",
+		Short: "Write a definition for the connected keyboard, with the effect names left to you",
+		Long: "Ask the keyboard how many effect IDs each of its lighting channels takes, then\n" +
+			"write a definition file with one unnamed option per ID, and a note beside it\n" +
+			"saying which spellings other keyboards' definitions use for the same numbers.\n\n" +
+			"The names are yours to write: open the file and put one in each options entry,\n" +
+			"and the channel starts resolving them. Nothing in a file can be read back off a\n" +
+			"keyboard, so a name the tool wrote would be indistinguishable from the\n" +
+			"manufacturer's, and a wrong name is worse than none: the tool reports an effect\n" +
+			"as unknown and set by number until you fill it in.\n\n" +
+			"A definition already stored for this keyboard is not replaced, because it is\n" +
+			"probably the one you wrote. Edit it, or pass --force.",
+		Args: cobra.NoArgs,
+		RunE: runKeyboardDefinitionsGenerate,
+	}
+	cmd.Flags().BoolVar(&generateForce, "force", false,
+		"Replace a definition that is already stored, discarding whatever that file holds")
+	return cmd
+}
+
+func runKeyboardDefinitionsGenerate(cmd *cobra.Command, args []string) error {
+	proto, target, channels, err := openTarget("")
+	if err != nil {
+		return err
+	}
+	defer proto.Close()
+
+	probe, ok := proto.(effectRangeProbe)
+	if !ok {
+		return fmt.Errorf("this build cannot read a keyboard's effect range")
+	}
+
+	stored := storedDefinitionsFor(definitionsPath(), target.Device.VendorID, target.Device.ProductID)
+	if len(stored) > 0 && !generateForce {
+		return fmt.Errorf("a definition for %s (0x%04X/0x%04X) is already at %s; it is probably the one "+
+			"you wrote, so generate does not replace it, use --force to overwrite it or edit that file",
+			stored[0].Name, target.Device.VendorID, target.Device.ProductID, describeDataDir(stored[0].Path))
+	}
+
+	if len(channels) == 0 {
+		return fmt.Errorf("this keyboard exposes no VIA lighting channels, so there is nothing to " +
+			"generate a definition for")
+	}
+
+	name := scaffoldName(target.Device.Name)
+	doc := scaffoldDefinition{
+		Name:      name,
+		VendorID:  fmt.Sprintf("0x%04X", target.Device.VendorID),
+		ProductID: fmt.Sprintf("0x%04X", target.Device.ProductID),
+	}
+	notes := &strings.Builder{}
+	tops := map[via.Channel]int{}
+
+	for _, ch := range channels {
+		top, err := probe.EffectTop(ch)
+		if err != nil {
+			return err
+		}
+		if menu := scaffoldMenu(ch, top); len(menu.Content) > 0 {
+			doc.Menus = append(doc.Menus, menu)
+		}
+		tops[ch] = top
+		writeCandidates(notes, ch, top)
+	}
+
+	dir, err := ensureDefinitionsDir()
+	if err != nil {
+		return err
+	}
+	base := definitionFileName(&fetchedDefinition{
+		Definition: &intrgb.Definition{
+			Name:      name,
+			VendorID:  target.Device.VendorID,
+			ProductID: target.Device.ProductID,
+		},
+	})
+
+	encoded, err := json.MarshalIndent(doc, "", "  ")
+	if err != nil {
+		return err
+	}
+	path := filepath.Join(dir, base)
+	if err := os.WriteFile(path, append(encoded, '\n'), 0o644); err != nil {
+		return fmt.Errorf("write %s: %w", path, err)
+	}
+
+	notePath := strings.TrimSuffix(path, ".json") + spottedNoteSuffix
+	if err := os.WriteFile(notePath, []byte(notes.String()), 0o644); err != nil {
+		return fmt.Errorf("write %s: %w", notePath, err)
+	}
+
+	verb := "Wrote"
+	if len(stored) > 0 {
+		verb = "Replaced"
+	}
+	fmt.Fprintf(cmd.OutOrStdout(), "%s a definition for %s (%s/%s) to %s\n",
+		verb, doc.Name, doc.VendorID, doc.ProductID, describeDataDir(path))
+	fmt.Fprintf(cmd.OutOrStdout(), "Every effect is an unnamed option. Open the file and write a name into each one:\n")
+	for _, ch := range channels {
+		if _, ok := intrgb.EffectValueKey(ch); !ok {
+			continue
+		}
+		fmt.Fprintf(cmd.OutOrStdout(), "  %-10s %d slots, IDs 0 to %d\n", ch.Subsystem(), tops[ch]+1, tops[ch])
+	}
+	fmt.Fprintf(cmd.OutOrStdout(), "\nWhat other keyboards call the same numbers is in %s\n", describeDataDir(notePath))
+	return nil
+}
+
+// spottedNoteSuffix names the note beside a generated definition. It is not a
+// .json file, so the definitions directory does not read it as one: it carries
+// no definition, only what was seen.
+const spottedNoteSuffix = ".spotted.txt"
+
+// scaffoldName is the board's name in a generated definition. A keyboard that
+// reports no product string gets a name that says so rather than an empty one,
+// which would match nothing and read as a broken file.
+func scaffoldName(productString string) string {
+	if strings.TrimSpace(productString) == "" {
+		return "Unnamed keyboard"
+	}
+	return productString
+}
+
+// scaffoldDefinition is the shape of a VIA definition with nothing in it yet.
+// The identifiers are the board's, so the file is matched to it the way any
+// other definition is.
+type scaffoldDefinition struct {
+	Name string `json:"name"`
+	// The identifiers are hex strings, which is how a definition file spells them
+	// and how the parser reads them back. VIA's own served files carry the
+	// packed number instead; both are read and this is the one a human edits.
+	VendorID  string          `json:"vendorId"`
+	ProductID string          `json:"productId"`
+	Menus     []scaffoldMenuT `json:"menus"`
+}
+
+type scaffoldMenuT struct {
+	Label   string          `json:"label"`
+	Content []scaffoldEntry `json:"content"`
+}
+
+type scaffoldEntry struct {
+	Label   string `json:"label"`
+	Type    string `json:"type"`
+	Content []any  `json:"content"`
+	Options any    `json:"options,omitempty"`
+}
+
+// scaffoldMenu is one lighting channel as a VIA menu: the three controls the
+// value IDs name, with the effect list holding one unnamed option per slot. The
+// name is the channel's own, so the file names it the way the tool does rather
+// than inventing a label — a board the user fills in can rename it.
+func scaffoldMenu(ch via.Channel, top int) scaffoldMenuT {
+	valueKey, ok := intrgb.EffectValueKey(ch)
+	if !ok {
+		// A channel with no registered value key has no list this tool reads, so
+		// it is left out rather than written as something that would not load.
+		return scaffoldMenuT{}
+	}
+	channel := int(ch)
+	// The three controls address different value IDs under keys that share the
+	// effect key's prefix, and the prefix ends where "_effect" starts. Trimming
+	// the suffix and putting the underscore back is what keeps a key spelled
+	// `id_qmk_rgb_matrix_brightness` rather than one the parser will not find.
+	prefix := strings.TrimSuffix(valueKey, "_effect")
+
+	options := make([][]any, 0, top+1)
+	for id := 0; id <= top; id++ {
+		// The empty name is the point: it states a slot without naming it, and the
+		// parser skips it, so the channel reports no effects until a name is
+		// written here.
+		options = append(options, []any{"", id})
+	}
+
+	return scaffoldMenuT{
+		Label: ch.Subsystem(),
+		Content: []scaffoldEntry{
+			{
+				Label:   "Brightness",
+				Type:    "range",
+				Content: []any{prefix + "_brightness", channel, viaValueBrightness},
+				Options: []any{0, 255},
+			},
+			{
+				Label:   "Effect",
+				Type:    "dropdown",
+				Content: []any{valueKey, channel, viaValueEffect},
+				Options: options,
+			},
+			{
+				Label:   "Effect Speed",
+				Type:    "range",
+				Content: []any{prefix + "_effect_speed", channel, viaValueSpeed},
+				Options: []any{0, 255},
+			},
+		},
+	}
+}
+
+// writeCandidates records what other keyboards' definitions were seen to call
+// each ID, with the counts and the manufacturer, because a name without those is
+// a guess wearing a number.
+func writeCandidates(out *strings.Builder, ch via.Channel, top int) {
+	valueKey, ok := intrgb.EffectValueKey(ch)
+	if !ok {
+		return
+	}
+	fmt.Fprintf(out, "\n%s (channel %d), IDs 0 to %d\n", ch.Subsystem(), ch, top)
+	seen := 0
+	for id := 0; id <= top; id++ {
+		obs, ok := spotted.Candidates(valueKey, id)
+		if !ok {
+			continue
+		}
+		seen++
+		names := make([]string, 0, len(obs.Candidates))
+		for _, c := range obs.Candidates {
+			names = append(names, fmt.Sprintf("%s (%d)", c.Name, c.Count))
+		}
+		line := fmt.Sprintf("  ID %-3d seen on %3d boards: %s", id, obs.Boards, strings.Join(names, ", "))
+		if obs.TopVendor != "" && obs.TopVendorCount > 0 {
+			line += fmt.Sprintf("   most of them %s (%d)", obs.TopVendor, obs.TopVendorCount)
+		}
+		fmt.Fprintln(out, line)
+	}
+	if seen == 0 {
+		fmt.Fprintf(out, "  no definition anywhere names an effect on this channel\n")
+	}
+}

+ 232 - 0
cmd/qmk-rgb-tool/definition_generate_test.go

@@ -0,0 +1,232 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
+	intvia "netdome.biz/paul/qmk-rgb/internal/via"
+)
+
+// generateStub is a keyboard that answers an effect range, so the generated
+// scaffold can be checked without hardware.
+type generateStub struct {
+	tops map[intvia.Channel]int
+}
+
+func (g generateStub) EffectTop(ch intvia.Channel) (int, error) {
+	return g.tops[ch], nil
+}
+
+func (g generateStub) GetValue(intvia.Channel, uint8) ([]byte, error) { return []byte{0}, nil }
+
+func (g generateStub) SetValue(intvia.Channel, uint8, uint8) error { return nil }
+
+func (g generateStub) SetColor(intvia.Channel, uint8, uint8) error { return nil }
+
+func (g generateStub) DetectChannels() ([]intvia.Channel, error) { return nil, nil }
+
+func (g generateStub) Close() error { return nil }
+
+func stubGenerateTarget(t *testing.T, channels []intvia.Channel, tops map[intvia.Channel]int) {
+	t.Helper()
+	original := openTarget
+	t.Cleanup(func() { openTarget = original })
+	openTarget = func(string) (rgbProtocol, targetDeviceData, []intvia.Channel, error) {
+		return generateStub{tops: tops}, stubTargetData(0x36B0, 0x309F), channels, nil
+	}
+}
+
+func forceGenerateRestore(t *testing.T) func() {
+	t.Helper()
+	original := generateForce
+	return func() { generateForce = original }
+}
+
+// The whole point of the command: a generated file names no effect, so a board
+// with a generated definition reports the same "no names" it reported before one
+// existed. A scaffold that claimed names would be a guess the tool could not
+// read back off the keyboard.
+func TestGeneratedDefinitionNamesNoEffectUntilTheUserFillsItIn(t *testing.T) {
+	dir := t.TempDir()
+	t.Cleanup(definitionFlagRestore(t))
+	t.Cleanup(forceDefinitionsDir(t, dir))
+	t.Cleanup(forceGenerateRestore(t))
+	stubGenerateTarget(t, []intvia.Channel{intvia.ChannelRgbMatrix}, map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45})
+
+	cmd := NewKeyboardDefinitionsGenerateCmd()
+	var out, errOut strings.Builder
+	cmd.SetOut(&out)
+	cmd.SetErr(&errOut)
+
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("definitions generate error = %v (stderr %q)", err, errOut.String())
+	}
+
+	path := onlyDefinition(t, dir)
+	def, err := intrgb.ParseDefinition(path, []byte(mustRead(t, path)))
+	if err != nil {
+		t.Fatalf("ParseDefinition(%s) error = %v; the generated file has to load", path, err)
+	}
+	if got := len(def.Catalog.Effects(intvia.ChannelRgbMatrix)); got != 0 {
+		t.Errorf("effects on rgb_matrix = %d, want 0 until the names are written in", got)
+	}
+	// The slots are still there, which is the other half: the file states which
+	// IDs exist and leaves the naming to the user.
+	var file struct {
+		Menus []struct {
+			Content []struct {
+				Label   string `json:"label"`
+				Type    string `json:"type"`
+				Content []any  `json:"content"`
+				Options []any  `json:"options"`
+			} `json:"content"`
+		} `json:"menus"`
+	}
+	if err := json.Unmarshal([]byte(mustRead(t, path)), &file); err != nil {
+		t.Fatalf("unmarshal generated file: %v", err)
+	}
+	var options []any
+	for _, menu := range file.Menus {
+		for _, entry := range menu.Content {
+			if entry.Type == "dropdown" {
+				options = entry.Options
+			}
+		}
+	}
+	if len(options) != 46 {
+		t.Fatalf("effect options = %d, want 46, one per ID from 0 to 45", len(options))
+	}
+	for i, option := range options {
+		pair, ok := option.([]any)
+		if !ok || len(pair) != 2 {
+			t.Fatalf("option %d = %v, want a name and a number", i, option)
+		}
+		if name, _ := pair[0].(string); name != "" {
+			t.Errorf("option %d is named %q, want a slot with no name", i, name)
+		}
+		if number, _ := pair[1].(float64); int(number) != i {
+			t.Errorf("option %d carries ID %v, want the slot numbered %d", i, pair[1], i)
+		}
+	}
+
+	// Every control has to be addressed by a value key the parser recognises. A
+	// key built by trimming a suffix and appending without the underscore is
+	// `id_qmk_rgb_matrixbrightness`, which names nothing, and nothing in the
+	// output above would say so.
+	wantKeys := map[string]bool{
+		"id_qmk_rgb_matrix_brightness":   false,
+		"id_qmk_rgb_matrix_effect":       false,
+		"id_qmk_rgb_matrix_effect_speed": false,
+	}
+	for _, menu := range file.Menus {
+		for _, entry := range menu.Content {
+			key, _ := entry.Content[0].(string)
+			if _, ok := wantKeys[key]; !ok {
+				t.Errorf("control %q is addressed by %q, which is not a VIA value key", entry.Label, key)
+				continue
+			}
+			wantKeys[key] = true
+		}
+	}
+	for key, found := range wantKeys {
+		if !found {
+			t.Errorf("no control addresses %q", key)
+		}
+	}
+}
+
+// A definition a user has written is the one command they would have run to write
+// it, so it must survive a second run of the command.
+func TestGenerateDoesNotReplaceAStoredDefinition(t *testing.T) {
+	dir := t.TempDir()
+	t.Cleanup(definitionFlagRestore(t))
+	t.Cleanup(forceDefinitionsDir(t, dir))
+	t.Cleanup(forceGenerateRestore(t))
+	stubGenerateTarget(t, []intvia.Channel{intvia.ChannelRgbMatrix}, map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45})
+
+	written := `{"name":"Test Board","vendorId":"0x36B0","productId":"0x309F","menus":[],"note":"hand written"}`
+	if err := os.WriteFile(filepath.Join(dir, "test_board.json"), []byte(written), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	cmd := NewKeyboardDefinitionsGenerateCmd()
+	var out, errOut strings.Builder
+	cmd.SetOut(&out)
+	cmd.SetErr(&errOut)
+
+	err := cmd.Execute()
+	if err == nil {
+		t.Fatal("definitions generate = nil error, want a refusal to replace a stored definition")
+	}
+	if !strings.Contains(err.Error(), "--force") {
+		t.Errorf("error = %q, want it to offer --force", err)
+	}
+	if got := mustRead(t, filepath.Join(dir, "test_board.json")); got != written {
+		t.Errorf("stored file = %q, want it untouched (%q)", got, written)
+	}
+}
+
+// The note beside the file is what carries the names, because a JSON file cannot
+// hold them: the tool would have to read comments and VIA's parser would reject
+// them. It has to say who wrote what, or a name is a guess wearing a count.
+func TestGeneratedNoteCarriesTheSpellingsAndWhoWroteThem(t *testing.T) {
+	dir := t.TempDir()
+	t.Cleanup(definitionFlagRestore(t))
+	t.Cleanup(forceDefinitionsDir(t, dir))
+	t.Cleanup(forceGenerateRestore(t))
+	stubGenerateTarget(t, []intvia.Channel{intvia.ChannelRgbMatrix}, map[intvia.Channel]int{intvia.ChannelRgbMatrix: 45})
+
+	cmd := NewKeyboardDefinitionsGenerateCmd()
+	var out, errOut strings.Builder
+	cmd.SetOut(&out)
+	cmd.SetErr(&errOut)
+	if err := cmd.Execute(); err != nil {
+		t.Fatalf("definitions generate error = %v", err)
+	}
+
+	note := mustRead(t, strings.TrimSuffix(onlyDefinition(t, dir), ".json")+spottedNoteSuffix)
+	if !strings.Contains(note, "rgb_matrix (channel 3), IDs 0 to 45") {
+		t.Errorf("note = %q, want it to name the channel and the ID range", note)
+	}
+	// The measurement behind the names: a spelling, how many boards wrote it, and
+	// which manufacturer most of them were.
+	if !strings.Contains(note, "rainbow_moving_chevron") {
+		t.Errorf("note = %q, want the spellings other definitions use", note)
+	}
+	if !strings.Contains(note, "boards:") {
+		t.Errorf("note = %q, want a count of boards next to every name", note)
+	}
+	if !strings.Contains(note, "keychron") {
+		t.Errorf("note = %q, want the manufacturer behind most of a name's spellings", note)
+	}
+	// Effect 23 is where the collection disagrees about what the number even is,
+	// so the note has to show the runner-up rather than pick a winner.
+	if !strings.Contains(note, "ID 23 ") {
+		t.Errorf("note = %q, want a line for ID 23", note)
+	}
+}
+
+func onlyDefinition(t *testing.T, dir string) string {
+	t.Helper()
+	matches, err := filepath.Glob(filepath.Join(dir, "*.json"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(matches) != 1 {
+		t.Fatalf("definitions dir = %v, want one JSON file", matches)
+	}
+	return matches[0]
+}
+
+func mustRead(t *testing.T, path string) string {
+	t.Helper()
+	data, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return string(data)
+}

+ 7 - 1
cmd/qmk-rgb-tool/main.go

@@ -173,10 +173,16 @@ func init() {
 // same tree the binary runs and check the grouping is real, rather than repeating
 // a list that could fall behind the one that ships.
 func registerCommands(root *cobra.Command) {
+	definitionsCmd := NewKeyboardDefinitionsCmd()
+	// `generate` hangs off `definitions` because it writes one of the files that
+	// command lists. A parent with a RunE of its own still runs when no
+	// subcommand is named, so `keyboard definitions` keeps listing.
+	definitionsCmd.AddCommand(NewKeyboardDefinitionsGenerateCmd())
+
 	keyboardCmd.AddCommand(
 		keyboardInfoCmd,
 		NewKeyboardFetchCmd(),
-		NewKeyboardDefinitionsCmd(),
+		definitionsCmd,
 	)
 
 	// The zone is a positional argument, not a flag. A flag every help lists told

+ 21 - 0
internal/rgb/definition.go

@@ -287,6 +287,14 @@ func effectList(control map[string]any) (channelEffects, bool) {
 			// no name to report; skipping it keeps the rest of the list.
 			continue
 		}
+		if strings.TrimSpace(name) == "" {
+			// An option with an empty name states a slot without naming it. That
+			// is what a scaffold a user is meant to fill in looks like, and a
+			// board that has one is not a board with an effect called "": the
+			// slot is known and the name is not, so the channel reports no
+			// effects until the name is there.
+			continue
+		}
 		if !hasID {
 			id = uint8(position)
 		}
@@ -398,3 +406,16 @@ func FindDefinition(defs []*Definition, vendorID, productID uint16) *Definition
 	}
 	return nil
 }
+
+// EffectValueKey returns the VIA value key a channel's effect list is filed
+// under, and whether one is registered for it. The lookup goes through
+// effectValueKeys rather than a second table, because that map is where a value
+// key is registered and a second one would fall behind it.
+func EffectValueKey(ch via.Channel) (string, bool) {
+	for key, channel := range effectValueKeys {
+		if channel == ch {
+			return key, true
+		}
+	}
+	return "", false
+}

+ 352 - 0
internal/spotted/generate/main.go

@@ -0,0 +1,352 @@
+// Command generate reads a checkout of VIA's public definition collection and
+// writes internal/spotted/spotted.json: for every effect ID a board's
+// definitions have been seen to use, which spellings occur and how often.
+//
+// It exists because the alternative is a name the tool made up. No definition
+// file, firmware image, protocol query or version number yields the spelling of
+// an effect for a board whose vendor never wrote one down, and README says so
+// rather than filling the gap. What can be measured is what the boards that did
+// write theirs call the same number, which is evidence and not an answer: the
+// measurement this tool takes itself finds that 95 of the 123 definitions
+// carrying an rgb_matrix effect list are one manufacturer's, so a bare majority
+// would report one house style as a community consensus. The counts and the
+// vendor concentration travel with every name for that reason.
+//
+// The output is generated, never hand-edited, and it carries the commit it was
+// measured from. It is a snapshot of a collection that moves, so a name in it
+// can be out of date; the SHA is what says how out of date.
+//
+// Usage:
+//
+//	generate <path-to-the-via/keyboards> > internal/spotted/spotted.json
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/fs"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+)
+
+// effectValueKeySuffix is what a VIA value key for an effect ends in. All effect
+// dropdowns in the collection use a QMK value key, so keying on the suffix keeps
+// this from needing a list of the keys themselves.
+const effectValueKeySuffix = "_effect"
+
+// maxCandidates is how many spellings per ID are kept. Three is what makes the
+// disagreement visible: where two boards call one number different effects, the
+// runner-up is the whole point, and a longer tail would only bury it.
+const maxCandidates = 3
+
+// Document is one board's effect list, keyed by the VIA value key it was under.
+type Document struct {
+	Vendor  string
+	Effects map[string]map[int]string
+}
+
+// Candidate is one spelling seen for an effect ID, and how many boards wrote it.
+type Candidate struct {
+	Name  string `json:"name"`
+	Count int    `json:"count"`
+}
+
+// Observation is what was seen for one effect ID under one value key.
+type Observation struct {
+	// Boards is how many definitions listed this ID at all. It is the sample
+	// size, and a low one is why a name here is worth less than a name a
+	// definition file carries.
+	Boards int `json:"boards"`
+	// Candidates are the spellings, most frequent first.
+	Candidates []Candidate `json:"candidates"`
+	// TopVendor is the manufacturer behind the most of them, and TopVendorCount
+	// how many. A name whose share is nearly all one vendor is that vendor's
+	// spelling, not a community one, and this is what says so.
+	TopVendor      string `json:"topVendor,omitempty"`
+	TopVendorCount int    `json:"topVendorCount,omitempty"`
+}
+
+// Report is the whole measurement, and what it was measured from.
+type Report struct {
+	// Source and Commit pin the collection. A name in here is a claim about the
+	// collection at that commit, and a collection that moves means the claim
+	// ages.
+	Source string `json:"source"`
+	Commit string `json:"commit,omitempty"`
+	// Definitions is how many files were read, and EffectBoards how many of them
+	// carried an effect list at all. The two differ by an order of magnitude,
+	// which is the size of the problem this file cannot solve.
+	Definitions  int `json:"definitions"`
+	EffectBoards int `json:"effectBoards"`
+	// Effects is keyed by the VIA value key, then by effect ID as a string,
+	// because JSON object keys are strings and the IDs are bytes.
+	Effects map[string]map[string]Observation `json:"effects"`
+}
+
+func main() {
+	if len(os.Args) != 2 {
+		fmt.Fprintln(os.Stderr, "usage: generate <path-to-the-via/keyboards>")
+		os.Exit(2)
+	}
+	root := os.Args[1]
+	report, err := measure(root)
+	if err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+	enc := json.NewEncoder(os.Stdout)
+	enc.SetIndent("", " ")
+	if err := enc.Encode(report); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}
+
+func measure(root string) (*Report, error) {
+	v3 := filepath.Join(root, "v3")
+	// The collection nests to a varying depth, so it is walked rather than
+	// globbed: a glob of a fixed depth reads a fraction of it, and a measurement
+	// over a fraction is a measurement of something else.
+	var paths []string
+	err := filepath.WalkDir(v3, func(path string, entry fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") {
+			paths = append(paths, path)
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	sort.Strings(paths)
+
+	report := &Report{
+		Source:      "https://github.com/the-via/keyboards",
+		Commit:      commitOf(root),
+		Effects:     map[string]map[string]Observation{},
+		Definitions: len(paths),
+	}
+
+	// spellings[valueKey][id][spelling] and vendors[valueKey][id][vendor], so one
+	// pass over the collection answers every ID at once.
+	spellings := map[string]map[int]map[string]int{}
+	vendors := map[string]map[int]map[string]int{}
+
+	for _, path := range paths {
+		doc := readDocument(path)
+		if len(doc.Effects) == 0 {
+			continue
+		}
+		report.EffectBoards++
+		for key, effects := range doc.Effects {
+			if spellings[key] == nil {
+				spellings[key] = map[int]map[string]int{}
+				vendors[key] = map[int]map[string]int{}
+			}
+			for id, name := range effects {
+				if spellings[key][id] == nil {
+					spellings[key][id] = map[string]int{}
+					vendors[key][id] = map[string]int{}
+				}
+				spellings[key][id][normalise(name)]++
+				vendors[key][id][doc.Vendor]++
+			}
+		}
+	}
+
+	for key, byID := range spellings {
+		ids := make([]int, 0, len(byID))
+		for id := range byID {
+			ids = append(ids, id)
+		}
+		sort.Ints(ids)
+		report.Effects[key] = map[string]Observation{}
+		for _, id := range ids {
+			report.Effects[key][fmt.Sprint(id)] = observe(byID[id], vendors[key][id])
+		}
+	}
+	return report, nil
+}
+
+// observe turns the counts for one ID into what the report carries for it.
+func observe(byName map[string]int, byVendor map[string]int) Observation {
+	obs := Observation{Boards: 0}
+	for _, n := range byName {
+		obs.Boards += n
+	}
+	type pair struct {
+		name  string
+		count int
+	}
+	pairs := make([]pair, 0, len(byName))
+	for name, count := range byName {
+		pairs = append(pairs, pair{name, count})
+	}
+	// Ties are broken by name so the output is the same for the same input.
+	sort.Slice(pairs, func(i, j int) bool {
+		if pairs[i].count != pairs[j].count {
+			return pairs[i].count > pairs[j].count
+		}
+		return pairs[i].name < pairs[j].name
+	})
+	for i, p := range pairs {
+		if i >= maxCandidates {
+			break
+		}
+		obs.Candidates = append(obs.Candidates, Candidate{Name: p.name, Count: p.count})
+	}
+	for vendor, count := range byVendor {
+		if count > obs.TopVendorCount {
+			obs.TopVendor, obs.TopVendorCount = vendor, count
+		}
+	}
+	return obs
+}
+
+// readDocument reads one definition file and returns the effect dropdowns it
+// carries. A file that does not parse, or that carries none, is not an error:
+// the collection holds files for boards that do not light up, and a measurement
+// that stopped at the first of them would describe nothing.
+func readDocument(path string) Document {
+	doc := Document{Effects: map[string]map[int]string{}}
+	// The vendor is the directory above the board's, which is how the collection
+	// is laid out: v3/<vendor>/<board>/.../<board>.json. For a two-segment path it
+	// is the second segment.
+	parts := strings.Split(filepath.ToSlash(path), "/")
+	for i, p := range parts {
+		if p == "v3" && i+1 < len(parts) {
+			doc.Vendor = parts[i+1]
+			break
+		}
+	}
+	raw, err := os.ReadFile(path)
+	if err != nil {
+		return doc
+	}
+	var file any
+	if err := json.Unmarshal(raw, &file); err != nil {
+		return doc
+	}
+	walk(file, "", doc.Effects)
+	return doc
+}
+
+// walk finds every dropdown whose value key names an effect. The key is read
+// from the dropdown's own content rather than from a label, because the label is
+// a display string and the key is the address.
+func walk(node any, key string, out map[string]map[int]string) {
+	switch n := node.(type) {
+	case []any:
+		for _, child := range n {
+			walk(child, key, out)
+		}
+	case map[string]any:
+		content, _ := n["content"].([]any)
+		if n["type"] == "dropdown" && len(content) > 0 {
+			if valueKey, ok := content[0].(string); ok && strings.HasSuffix(valueKey, effectValueKeySuffix) {
+				if options, ok := n["options"].([]any); ok {
+					if parsed, ok := parseOptions(options); ok {
+						if out[valueKey] == nil {
+							out[valueKey] = map[int]string{}
+						}
+						for id, name := range parsed {
+							out[valueKey][id] = name
+						}
+					}
+				}
+			}
+		}
+		for _, child := range n {
+			walk(child, key, out)
+		}
+	}
+}
+
+// parseOptions reads a dropdown's options as number-name pairs. An option's
+// number is not its position, which is why this reads the number rather than
+// counting: a board may name its first effect 1 and have no 0 at all.
+func parseOptions(options []any) (map[int]string, bool) {
+	out := map[int]string{}
+	for _, option := range options {
+		pair, ok := option.([]any)
+		if !ok || len(pair) != 2 {
+			return nil, false
+		}
+		name, ok := pair[0].(string)
+		if !ok {
+			return nil, false
+		}
+		var number int
+		switch v := pair[1].(type) {
+		case float64:
+			number = int(v)
+		case string:
+			if _, err := fmt.Sscanf(v, "%d", &number); err != nil {
+				return nil, false
+			}
+		default:
+			return nil, false
+		}
+		out[number] = name
+	}
+	return out, len(out) > 0
+}
+
+// normalise reduces a display spelling to something a lookup can compare: case
+// and separators are the whole difference between a manufacturer's spelling and
+// the tool's, and `fixed wave` and `fixed_wave` are one effect.
+//
+// A leading number is decoration and is dropped. One manufacturer's definitions
+// write `07. RAINBOW_MOVING_CHEVRON` on every effect, and without this the most
+// frequent spelling of every ID would carry that vendor's numbering rather than
+// the name, which is the opposite of what a candidate is for. Two spellings that
+// differ only in such a prefix are the same name and merge.
+func normalise(name string) string {
+	var b strings.Builder
+	for _, r := range strings.ToLower(strings.TrimSpace(name)) {
+		switch {
+		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+			b.WriteRune(r)
+		default:
+			b.WriteRune('_')
+		}
+	}
+	return strings.TrimLeft(stripLeadingNumber(b.String()), "_")
+}
+
+// stripLeadingNumber removes a leading run of digits and the underscore after it,
+// so `07__rainbow_moving_chevron` becomes `rainbow_moving_chevron` and
+// `00__none` becomes `none`. A name that is only a number is left alone: that is
+// not decoration, and there is nothing else in it.
+func stripLeadingNumber(s string) string {
+	i := 0
+	for i < len(s) && s[i] >= '0' && s[i] <= '9' {
+		i++
+	}
+	if i == 0 || i == len(s) {
+		return s
+	}
+	for i < len(s) && s[i] == '_' {
+		i++
+	}
+	if i == len(s) {
+		return s
+	}
+	return s[i:]
+}
+
+// commitOf reads the commit a checkout is at, so the report says which one it
+// measured. A checkout without git, or a detached one, is not a reason to fail:
+// the report is still written, without the SHA, and says so in the field.
+func commitOf(root string) string {
+	out, err := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
+	if err != nil {
+		return ""
+	}
+	return strings.TrimSpace(string(out))
+}

+ 118 - 0
internal/spotted/spotted.go

@@ -0,0 +1,118 @@
+// Package spotted answers what effect names other keyboards' definitions have
+// been seen to use for a given effect ID.
+//
+// It is evidence, not an answer, and the difference is the point. No definition
+// file, firmware image, protocol query or version number yields the spelling of
+// an effect for a board whose manufacturer never wrote one down, and the README
+// says so instead of filling the gap. What can be measured is what the boards
+// that did write theirs call the same number — and the measurement this package
+// carries shows why that is not a name: of the 122 definitions in VIA's
+// collection that file an rgb_matrix effect list, 95 are one manufacturer's, so
+// the most frequent spelling of nearly every ID is one house style. Worse, the
+// disagreement where it exists is not about spelling. Effect 7 is
+// `rainbow_moving_chevron` on 95 boards and `cycle_out_in` on 8, which are
+// different effects, not two ways of writing one.
+//
+// So every name here travels with the number of boards that wrote it and the
+// manufacturer behind most of them, and a caller that shows one to a user has to
+// show those too. A name without them is a guess wearing a count.
+package spotted
+
+import (
+	_ "embed"
+	"encoding/json"
+	"strconv"
+	"sync"
+)
+
+//go:embed spotted.json
+var raw []byte
+
+// Candidate is one spelling seen for an effect ID, and how many boards wrote it.
+type Candidate struct {
+	Name  string `json:"name"`
+	Count int    `json:"count"`
+}
+
+// Observation is what was seen for one effect ID under one value key.
+type Observation struct {
+	// Boards is how many definitions listed this ID at all. It is the sample
+	// size: a name backed by four boards is worth less than one backed by a
+	// hundred and a half, and that difference is not visible anywhere else.
+	Boards int `json:"boards"`
+	// Candidates are the spellings, most frequent first.
+	Candidates []Candidate `json:"candidates"`
+	// TopVendor is the manufacturer behind the most of them and TopVendorCount how
+	// many. A name whose share is nearly all one vendor is that vendor's
+	// spelling, which is what this says.
+	TopVendor      string `json:"topVendor,omitempty"`
+	TopVendorCount int    `json:"topVendorCount,omitempty"`
+}
+
+// report is the generated measurement. Its shape is written by
+// internal/spotted/generate and must not be hand-edited.
+type report struct {
+	Source      string `json:"source"`
+	Commit      string `json:"commit"`
+	Definitions int    `json:"definitions"`
+	// EffectBoards is how many of Definitions carried an effect list with names.
+	// The gap to Definitions is the size of the problem: the rest either name one
+	// of VIA's built-in menus or do not light up at all.
+	EffectBoards int                               `json:"effectBoards"`
+	Effects      map[string]map[string]Observation `json:"effects"`
+}
+
+var (
+	once   sync.Once
+	loaded *report
+	parse  error
+)
+
+func get() (*report, error) {
+	once.Do(func() {
+		loaded = &report{}
+		parse = json.Unmarshal(raw, loaded)
+	})
+	return loaded, parse
+}
+
+// Candidates returns what has been seen for an effect ID under a VIA value key,
+// and whether anything was. An ID nothing was seen for is not an error: most IDs
+// above 23 are unobserved, because only a handful of boards go that far.
+func Candidates(valueKey string, id int) (Observation, bool) {
+	rep, err := get()
+	if err != nil {
+		return Observation{}, false
+	}
+	byID, ok := rep.Effects[valueKey]
+	if !ok {
+		return Observation{}, false
+	}
+	obs, ok := byID[strconv.Itoa(id)]
+	return obs, ok
+}
+
+// Provenance describes what the measurement is a measurement of, for a caller to
+// print: where it came from, which commit, and how much of the collection
+// carried an effect list at all.
+type Provenance struct {
+	Source       string
+	Commit       string
+	Definitions  int
+	EffectBoards int
+}
+
+// About reports where the measurement came from. A collection that moves means a
+// name in it ages, and the commit is what says how far.
+func About() (Provenance, bool) {
+	rep, err := get()
+	if err != nil {
+		return Provenance{}, false
+	}
+	return Provenance{
+		Source:       rep.Source,
+		Commit:       rep.Commit,
+		Definitions:  rep.Definitions,
+		EffectBoards: rep.EffectBoards,
+	}, true
+}

+ 1588 - 0
internal/spotted/spotted.json

@@ -0,0 +1,1588 @@
+{
+ "source": "https://github.com/the-via/keyboards",
+ "commit": "9e3e9f4aa9f50f9439fdebc747560f51aaf109a0",
+ "definitions": 2029,
+ "effectBoards": 161,
+ "effects": {
+  "id_custom_backlight_effect": {
+   "0": {
+    "boards": 1,
+    "candidates": [
+     {
+      "name": "none",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   },
+   "1": {
+    "boards": 1,
+    "candidates": [
+     {
+      "name": "breathing",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   },
+   "2": {
+    "boards": 1,
+    "candidates": [
+     {
+      "name": "blink_out_on_keypress",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   },
+   "3": {
+    "boards": 1,
+    "candidates": [
+     {
+      "name": "blink_in_on_keypress",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   }
+  },
+  "id_effect": {
+   "0": {
+    "boards": 33,
+    "candidates": [
+     {
+      "name": "all_off",
+      "count": 33
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 22
+   },
+   "1": {
+    "boards": 33,
+    "candidates": [
+     {
+      "name": "solid_color_1",
+      "count": 26
+     },
+     {
+      "name": "all_on",
+      "count": 7
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 22
+   },
+   "10": {
+    "boards": 26,
+    "candidates": [
+     {
+      "name": "radial_color_1",
+      "count": 26
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 15
+   },
+   "2": {
+    "boards": 33,
+    "candidates": [
+     {
+      "name": "alphas_mods_color_1_2",
+      "count": 24
+     },
+     {
+      "name": "raindrops",
+      "count": 7
+     },
+     {
+      "name": "custom_colors",
+      "count": 2
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 22
+   },
+   "3": {
+    "boards": 26,
+    "candidates": [
+     {
+      "name": "gradient_vertical_color_1_2",
+      "count": 26
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 15
+   },
+   "4": {
+    "boards": 26,
+    "candidates": [
+     {
+      "name": "raindrops_color_1_2",
+      "count": 26
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 15
+   },
+   "5": {
+    "boards": 26,
+    "candidates": [
+     {
+      "name": "cycle_all",
+      "count": 26
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 15
+   },
+   "6": {
+    "boards": 26,
+    "candidates": [
+     {
+      "name": "cycle_horizontal",
+      "count": 26
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 15
+   },
+   "7": {
+    "boards": 26,
+    "candidates": [
+     {
+      "name": "cycle_vertical",
+      "count": 26
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 15
+   },
+   "8": {
+    "boards": 26,
+    "candidates": [
+     {
+      "name": "jellybean_raindrops",
+      "count": 26
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 15
+   },
+   "9": {
+    "boards": 26,
+    "candidates": [
+     {
+      "name": "radial_all_hues",
+      "count": 26
+     }
+    ],
+    "topVendor": "wilba_tech",
+    "topVendorCount": 15
+   }
+  },
+  "id_qmk_backlight_effect": {
+   "0": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "off",
+      "count": 2
+     }
+    ],
+    "topVendor": "qvex",
+    "topVendorCount": 1
+   },
+   "1": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "breathing",
+      "count": 2
+     }
+    ],
+    "topVendor": "qvex",
+    "topVendorCount": 1
+   }
+  },
+  "id_qmk_rgb_matrix_effect": {
+   "0": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "none",
+      "count": 104
+     },
+     {
+      "name": "all_off",
+      "count": 17
+     },
+     {
+      "name": "led_off",
+      "count": 1
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "1": {
+    "boards": 123,
+    "candidates": [
+     {
+      "name": "solid_color",
+      "count": 123
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "10": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "cycle_pinwheel",
+      "count": 96
+     },
+     {
+      "name": "cycke_spiral",
+      "count": 6
+     },
+     {
+      "name": "spiral_sat_",
+      "count": 4
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "11": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "cycle_spiral",
+      "count": 93
+     },
+     {
+      "name": "dual_beacon",
+      "count": 9
+     },
+     {
+      "name": "cycle_pinwheel",
+      "count": 4
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "12": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "dual_beacon",
+      "count": 96
+     },
+     {
+      "name": "cycle_all",
+      "count": 8
+     },
+     {
+      "name": "rainbow_beacon",
+      "count": 8
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "13": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "rainbow_beacon",
+      "count": 96
+     },
+     {
+      "name": "raindrops",
+      "count": 9
+     },
+     {
+      "name": "cycle_left_right",
+      "count": 8
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "14": {
+    "boards": 120,
+    "candidates": [
+     {
+      "name": "jellybean_raindrops",
+      "count": 95
+     },
+     {
+      "name": "cycle_up_down",
+      "count": 8
+     },
+     {
+      "name": "typing_heatmap",
+      "count": 8
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "15": {
+    "boards": 120,
+    "candidates": [
+     {
+      "name": "pixel_rain",
+      "count": 95
+     },
+     {
+      "name": "rainbow_moving_chevron",
+      "count": 8
+     },
+     {
+      "name": "solid_reactive_simple",
+      "count": 8
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "16": {
+    "boards": 119,
+    "candidates": [
+     {
+      "name": "typing_heatmap",
+      "count": 95
+     },
+     {
+      "name": "cycle_out_in",
+      "count": 8
+     },
+     {
+      "name": "solid_reactive",
+      "count": 8
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "17": {
+    "boards": 117,
+    "candidates": [
+     {
+      "name": "digital_rain",
+      "count": 95
+     },
+     {
+      "name": "cycle_out_in_dual",
+      "count": 8
+     },
+     {
+      "name": "solid_reactive_cross",
+      "count": 6
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "18": {
+    "boards": 114,
+    "candidates": [
+     {
+      "name": "reactive_simple",
+      "count": 95
+     },
+     {
+      "name": "cycle_pinwheel",
+      "count": 8
+     },
+     {
+      "name": "matrix_multisplash",
+      "count": 6
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "19": {
+    "boards": 108,
+    "candidates": [
+     {
+      "name": "reactive_multiwide",
+      "count": 95
+     },
+     {
+      "name": "cycle_spiral",
+      "count": 8
+     },
+     {
+      "name": "hue_breathing",
+      "count": 3
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "2": {
+    "boards": 120,
+    "candidates": [
+     {
+      "name": "breathing",
+      "count": 106
+     },
+     {
+      "name": "gradient_up_down",
+      "count": 9
+     },
+     {
+      "name": "alphas_mods",
+      "count": 3
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "20": {
+    "boards": 108,
+    "candidates": [
+     {
+      "name": "reactive_multinexus",
+      "count": 95
+     },
+     {
+      "name": "dual_beacon",
+      "count": 8
+     },
+     {
+      "name": "hue_pendulum",
+      "count": 3
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "21": {
+    "boards": 108,
+    "candidates": [
+     {
+      "name": "splash",
+      "count": 95
+     },
+     {
+      "name": "rainbow_beacon",
+      "count": 8
+     },
+     {
+      "name": "hue_wave",
+      "count": 3
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "22": {
+    "boards": 108,
+    "candidates": [
+     {
+      "name": "solid_splash",
+      "count": 95
+     },
+     {
+      "name": "rainbow_pinwheels",
+      "count": 8
+     },
+     {
+      "name": "pixel_rain",
+      "count": 3
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "23": {
+    "boards": 12,
+    "candidates": [
+     {
+      "name": "raindrops",
+      "count": 6
+     },
+     {
+      "name": "pixel_flow",
+      "count": 3
+     },
+     {
+      "name": "flower_blooming",
+      "count": 2
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "24": {
+    "boards": 12,
+    "candidates": [
+     {
+      "name": "jellybean_raindrops",
+      "count": 5
+     },
+     {
+      "name": "pixel_fractal",
+      "count": 3
+     },
+     {
+      "name": "raindrops",
+      "count": 2
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "25": {
+    "boards": 11,
+    "candidates": [
+     {
+      "name": "hue_breathing",
+      "count": 6
+     },
+     {
+      "name": "typing_heatmap",
+      "count": 3
+     },
+     {
+      "name": "jellybean_raindrops",
+      "count": 2
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "26": {
+    "boards": 10,
+    "candidates": [
+     {
+      "name": "hue_pendulum",
+      "count": 5
+     },
+     {
+      "name": "digital_rain",
+      "count": 3
+     },
+     {
+      "name": "hue_breathing",
+      "count": 2
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "27": {
+    "boards": 10,
+    "candidates": [
+     {
+      "name": "hue_wave",
+      "count": 5
+     },
+     {
+      "name": "solid_reactive_simple",
+      "count": 3
+     },
+     {
+      "name": "hue_pendulum",
+      "count": 2
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "28": {
+    "boards": 10,
+    "candidates": [
+     {
+      "name": "pixel_rain",
+      "count": 5
+     },
+     {
+      "name": "solid_reactive",
+      "count": 3
+     },
+     {
+      "name": "hue_wave",
+      "count": 2
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "29": {
+    "boards": 10,
+    "candidates": [
+     {
+      "name": "pixel_flow",
+      "count": 4
+     },
+     {
+      "name": "solid_reactive_wide",
+      "count": 3
+     },
+     {
+      "name": "pixel_rain",
+      "count": 2
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "3": {
+    "boards": 123,
+    "candidates": [
+     {
+      "name": "band_spiral_val",
+      "count": 95
+     },
+     {
+      "name": "cycle_all",
+      "count": 10
+     },
+     {
+      "name": "gradient_left_right",
+      "count": 9
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "30": {
+    "boards": 9,
+    "candidates": [
+     {
+      "name": "pixel_fractal",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_multi_wide",
+      "count": 3
+     },
+     {
+      "name": "pixel_flow",
+      "count": 2
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "31": {
+    "boards": 8,
+    "candidates": [
+     {
+      "name": "solid_reactive_cross",
+      "count": 3
+     },
+     {
+      "name": "typing_heatmap",
+      "count": 3
+     },
+     {
+      "name": "pixel_fractal",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "32": {
+    "boards": 9,
+    "candidates": [
+     {
+      "name": "digital_rain",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_multi_cross",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_multiwide",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "33": {
+    "boards": 9,
+    "candidates": [
+     {
+      "name": "solid_reactive_nexus",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_simple",
+      "count": 3
+     },
+     {
+      "name": "digital_rain",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "34": {
+    "boards": 9,
+    "candidates": [
+     {
+      "name": "solid_reactive",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_multi_nexus",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_multicross",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "35": {
+    "boards": 9,
+    "candidates": [
+     {
+      "name": "solid_reactive_wide",
+      "count": 3
+     },
+     {
+      "name": "splash",
+      "count": 3
+     },
+     {
+      "name": "riverflow",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "36": {
+    "boards": 8,
+    "candidates": [
+     {
+      "name": "solid_splash",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_multiwide",
+      "count": 2
+     },
+     {
+      "name": "solid_reactive_multi_wide",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "37": {
+    "boards": 8,
+    "candidates": [
+     {
+      "name": "solid_multi_splash",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_cross",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_multi_wide",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "38": {
+    "boards": 8,
+    "candidates": [
+     {
+      "name": "starlight",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_multicross",
+      "count": 2
+     },
+     {
+      "name": "multisplash",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "39": {
+    "boards": 8,
+    "candidates": [
+     {
+      "name": "solid_reactive_nexus",
+      "count": 3
+     },
+     {
+      "name": "starlight_dual_sat_",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_multi_cross",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "4": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "cycle_all",
+      "count": 95
+     },
+     {
+      "name": "cycle_left_right",
+      "count": 10
+     },
+     {
+      "name": "breathing",
+      "count": 9
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "40": {
+    "boards": 8,
+    "candidates": [
+     {
+      "name": "starlight_dual_hue_",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_multinexus",
+      "count": 2
+     },
+     {
+      "name": "solid_multisplash",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "41": {
+    "boards": 7,
+    "candidates": [
+     {
+      "name": "riverflow",
+      "count": 3
+     },
+     {
+      "name": "splash",
+      "count": 3
+     },
+     {
+      "name": "solid_reactive_nexus",
+      "count": 1
+     }
+    ],
+    "topVendor": "mechboards",
+    "topVendorCount": 3
+   },
+   "42": {
+    "boards": 4,
+    "candidates": [
+     {
+      "name": "multisplash",
+      "count": 2
+     },
+     {
+      "name": "multi_splash",
+      "count": 1
+     },
+     {
+      "name": "solid_reactive_multi_nexus",
+      "count": 1
+     }
+    ],
+    "topVendor": "jidouhun",
+    "topVendorCount": 1
+   },
+   "43": {
+    "boards": 4,
+    "candidates": [
+     {
+      "name": "solid_splash",
+      "count": 3
+     },
+     {
+      "name": "spash",
+      "count": 1
+     }
+    ],
+    "topVendor": "adafruit",
+    "topVendorCount": 1
+   },
+   "44": {
+    "boards": 4,
+    "candidates": [
+     {
+      "name": "solid_multisplash",
+      "count": 2
+     },
+     {
+      "name": "multi_splash",
+      "count": 1
+     },
+     {
+      "name": "solid_multi_splash",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   },
+   "45": {
+    "boards": 1,
+    "candidates": [
+     {
+      "name": "solid_splash",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   },
+   "46": {
+    "boards": 1,
+    "candidates": [
+     {
+      "name": "solid_multi_splash",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   },
+   "47": {
+    "boards": 1,
+    "candidates": [
+     {
+      "name": "starlight",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   },
+   "48": {
+    "boards": 1,
+    "candidates": [
+     {
+      "name": "starlight_dual_sat_",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   },
+   "49": {
+    "boards": 1,
+    "candidates": [
+     {
+      "name": "starlight_dual_hue_",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   },
+   "5": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "cycle_left_right",
+      "count": 95
+     },
+     {
+      "name": "cycle_up_down",
+      "count": 10
+     },
+     {
+      "name": "breathing",
+      "count": 8
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "50": {
+    "boards": 1,
+    "candidates": [
+     {
+      "name": "riverflow",
+      "count": 1
+     }
+    ],
+    "topVendor": "era",
+    "topVendorCount": 1
+   },
+   "6": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "cycle_up_down",
+      "count": 95
+     },
+     {
+      "name": "rainbow_moving_chevron",
+      "count": 8
+     },
+     {
+      "name": "band_sat",
+      "count": 4
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "7": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "rainbow_moving_chevron",
+      "count": 95
+     },
+     {
+      "name": "cycle_out_in",
+      "count": 8
+     },
+     {
+      "name": "band_val",
+      "count": 4
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "8": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "cycle_out_in",
+      "count": 95
+     },
+     {
+      "name": "cycle_out_in_dual",
+      "count": 8
+     },
+     {
+      "name": "band_pinwheel_sat",
+      "count": 4
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   },
+   "9": {
+    "boards": 122,
+    "candidates": [
+     {
+      "name": "cycle_out_in_dual",
+      "count": 96
+     },
+     {
+      "name": "cycle_pinwheel",
+      "count": 8
+     },
+     {
+      "name": "band_pinwheel_val",
+      "count": 4
+     }
+    ],
+    "topVendor": "keychron",
+    "topVendorCount": 95
+   }
+  },
+  "id_qmk_rgblight_effect": {
+   "1": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "solid_color",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "10": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "rainbow_swirl_2",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "11": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "rainbow_swirl_3",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "12": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "rainbow_swirl_4",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "13": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "rainbow_swirl_5",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "14": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "rainbow_swirl_6",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "15": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "snake_1",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "16": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "snake_2",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "17": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "snake_3",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "18": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "snake_4",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "19": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "snake_5",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "2": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "breathing_1",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "20": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "snake_6",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "21": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "knight_1",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "22": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "knight_2",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "23": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "knight_3",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "24": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "christmas",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "25": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "gradient_1",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "26": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "gradient_2",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "27": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "gradient_3",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "28": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "gradient_4",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "29": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "gradient_5",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "3": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "breathing_2",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "30": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "gradient_6",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "31": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "gradient_7",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "32": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "gradient_8",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "33": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "gradient_9",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "34": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "gradient_10",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "35": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "rgb_test",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "36": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "alternating",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "37": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "twinkle_1",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "38": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "twinkle_2",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "39": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "twinkle_3",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "4": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "breathing_3",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "40": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "twinkle_4",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "41": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "twinkle_5",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "42": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "twinkle_6",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "5": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "breathing_4",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "6": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "rainbow_mood_1",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "7": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "rainbow_mood_2",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "8": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "rainbow_mood_3",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   },
+   "9": {
+    "boards": 2,
+    "candidates": [
+     {
+      "name": "rainbow_swirl_1",
+      "count": 2
+     }
+    ],
+    "topVendor": "cipulot",
+    "topVendorCount": 2
+   }
+  }
+ }
+}