package main import ( "fmt" "os" "path/filepath" "strconv" "strings" "github.com/spf13/cobra" intdevice "netdome.biz/paul/qmk-rgb/internal/device" 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 effect IDs each channel takes, a way to close it, and the two Vial calls // that may answer the question without writing to 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) VialVersion() (uint32, bool, error) VialEffectIDs() ([]uint16, error) Close() error } // slotsFrom is where a channel's effect slots came from. It is reported per // channel because the two answers are not the same kind of thing: one is the // firmware listing what it compiled in, the other is the firmware's answer to a // value written above the top. type slotsFrom string const ( // slotsMeasured means the range was found by writing above the top and // reading back what the firmware clamped to. slotsMeasured slotsFrom = "measured by clamping" // slotsReported means the firmware listed them itself, which is Vial's // vialrgb_get_supported and nothing else. slotsReported slotsFrom = "reported by the firmware" ) // experimentalVial marks the Vial path. It is written from Vial's source and // tested against a scripted keyboard, and nobody has run it against a Vial // keyboard, so every command that uses it says so rather than presenting an // unverified answer as a measurement. Remove the word when a real one has. const experimentalVial = "experimental" // 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 names file for the connected board: one line per effect ID the keyboard // reports, with no names in them, plus a note beside it saying which spellings // other keyboards' definitions use for the same numbers. // // It writes a names file and not a definition file, because a definition belongs // to the manufacturer. Writing one meant authoring a menu description for // someone else's keyboard and handing the user a nested JSON array to type a // single name into, and a name the tool put in a file it had written itself would // read as the manufacturer's own. A names file is flat — a channel, an effect ID // and a name — and it overrides the definition where it has one, so the same // command starts a board nobody published a file for and corrects a name on a // board they did. // // The names are the user's to write. A name is the one value this tool cannot // read back off a keyboard, so a name the tool chose would be indistinguishable // from one the board's vendor published. An empty name states a slot without // naming it, and is skipped, so the channel reports no effects until one is // filled in. func NewKeyboardDefinitionsGenerateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "generate", Short: "Write a names file for the connected keyboard, with the effect names left to you", Long: experimentalVial + ": the Vial path below has not been run against a Vial keyboard.\n\n" + "Ask the keyboard how many effect IDs each of its lighting channels takes, then\n" + "write a names file with one line per ID and no name in it, 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 on each line, and the\n" + "channel starts resolving them. A name you write for an ID the board's definition\n" + "also names replaces that one name and leaves the rest alone, so this also corrects\n" + "a board whose definition this tool has.\n\n" + "A names file already stored for this keyboard is not replaced, because it is\n" + "probably the one you wrote. Edit it, or pass --force.\n\n" + "A keyboard running Vial firmware is asked for its rgb_matrix effect IDs instead of\n" + "being written to, which is the better answer, and it numbers them in its own\n" + "namespace rather than QMK's. Every channel says where its slots came from.", 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 := storedNamesFor(target.Device.VendorID, target.Device.ProductID) if len(stored) > 0 && !generateForce { return fmt.Errorf("a names file for 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", 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{} // Vial can list the rgb_matrix effect IDs without the keyboard being written // to. It is asked first because its answer is the better one, and only where // the firmware serves it: VIALRGB_ENABLE is a build option, and a Vial // keyboard without it answers nothing. version, isVial, err := probe.VialVersion() if err != nil { return err } if isVial { fmt.Fprintf(cmd.OutOrStdout(), "This keyboard answers as Vial firmware, protocol version %d (%s: not run against real hardware)\n", version, experimentalVial) } sources := map[via.Channel]slotsFrom{} slots := map[via.Channel][]uint16{} for _, ch := range channels { ids, from, err := slotsFor(probe, ch, isVial) if err != nil { return err } if menu := scaffoldMenu(ch, ids); len(menu.Content) > 0 { doc.Menus = append(doc.Menus, menu) } sources[ch] = from slots[ch] = ids writeCandidates(notes, ch, ids, from) } dir, err := ensureNamesDir() if err != nil { return err } path := filepath.Join(dir, namesFileName(target.Device)) body, err := renderNamesFile(name, target.Device, slots) if err != nil { return err } if err := os.WriteFile(path, body, 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 names file for %s (%04X/%04X) to %s\n", verb, name, target.Device.VendorID, target.Device.ProductID, describeDataDir(path)) fmt.Fprintf(cmd.OutOrStdout(), "Every effect is a line with no name in it. Open the file and write one in:\n") for _, ch := range channels { ids, ok := slots[ch] if !ok { continue } fmt.Fprintf(cmd.OutOrStdout(), " %-10s %d slots, IDs %s (%s)\n", ch.Subsystem(), len(ids), describeSlotRange(ids), sources[ch]) } fmt.Fprintf(cmd.OutOrStdout(), "\nWhat other keyboards call the same numbers is in %s\n", describeDataDir(notePath)) if def := loadedDefinition(target.Device.VendorID, target.Device.ProductID); def != nil { fmt.Fprintf(cmd.OutOrStdout(), "\nThis board also has a definition naming %d effects; a name you write here\nreplaces that one name and leaves the others alone.\n", namedEffectCount(def)) } 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"` } // slotsFor returns the effect IDs a channel has and where that came from. // // Vial is asked only for rgb_matrix, because VialRGB is an rgb_matrix extension: // there is no Vial list for backlight, rgblight, audio or led_matrix, and those // keep the clamping answer. A Vial keyboard without VIALRGB_ENABLE answers // nothing, which is an error rather than an empty list, and it falls back to the // measurement because a board that cannot list its effects can still be asked. func slotsFor(probe effectRangeProbe, ch via.Channel, isVial bool) ([]uint16, slotsFrom, error) { if isVial && ch == via.ChannelRgbMatrix { ids, err := probe.VialEffectIDs() if err == nil && len(ids) > 0 { return ids, slotsReported, nil } if err != nil { fmt.Fprintf(os.Stderr, "vialrgb effect list on %s unavailable (%v), falling back to the clamp\n", ch.Subsystem(), err) } } top, err := probe.EffectTop(ch) if err != nil { return nil, "", err } ids := make([]uint16, 0, top+1) for id := 0; id <= top; id++ { ids = append(ids, uint16(id)) } return ids, slotsMeasured, nil } // describeSlotRange writes a list of IDs as a range when it is one, because // "0 to 45" says in six characters what a list of forty-six does not. func describeSlotRange(ids []uint16) string { if len(ids) == 0 { return "none" } runs := true for i := 1; i < len(ids); i++ { if ids[i] != ids[i-1]+1 { runs = false break } } if runs { return fmt.Sprintf("%d to %d", ids[0], ids[len(ids)-1]) } shown := ids if len(shown) > 8 { shown = append(append([]uint16{}, ids[:8]...), 0xFFFF) } parts := make([]string, 0, len(shown)) for _, id := range shown { if id == 0xFFFF { parts = append(parts, "...") continue } parts = append(parts, strconv.Itoa(int(id))) } return strings.Join(parts, ", ") } // 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, ids []uint16) 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, len(ids)) for _, id := range ids { // 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, ids []uint16, from slotsFrom) { valueKey, ok := intrgb.EffectValueKey(ch) if !ok { return } fmt.Fprintf(out, "\n%s (channel %d), %d slots, IDs %s (%s)\n", ch.Subsystem(), ch, len(ids), describeSlotRange(ids), from) if from == slotsReported { // The firmware that listed these numbers numbers them in its own // namespace, and the measurement below is QMK's. Saying "here is what // other boards call ID 7" next to a slot that is not QMK's ID 7 is the one // thing this note must not do. fmt.Fprintf(out, " These IDs are the firmware's own numbering, not QMK's: the list the firmware sends\n") fmt.Fprintf(out, " is VIALRGB_EFFECT_* and the translation to QMK's stays inside the firmware. The\n") fmt.Fprintf(out, " spellings other keyboards use are indexed by QMK's numbers, so they are not listed here.\n") return } seen := 0 for _, id := range ids { obs, ok := spotted.Candidates(valueKey, int(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") } } // namedEffectCount is how many effects a definition names across the channels it // covers. A file is a board's whole catalog, so the number the user loses is the // total and not the largest channel. func namedEffectCount(def *intrgb.Definition) int { total := 0 for _, ch := range via.LightingChannels { total += len(def.Catalog.Effects(ch)) } return total } // storedNamesFor returns the names files already in the per-user directory that // describe a board. func storedNamesFor(vendorID, productID uint16) []*intrgb.Names { var out []*intrgb.Names for _, names := range candidateNames() { if names.Matches(vendorID, productID) { out = append(out, names) } } return out } // namesFileName names a names file after the board, the same way a definition // file is named so that a directory of either is readable. func namesFileName(device intdevice.Device) string { var b strings.Builder for _, r := range strings.ToLower(device.Name) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) default: b.WriteRune('_') } } slug := strings.Trim(b.String(), "_") if slug == "" { slug = "keyboard" } return fmt.Sprintf("%s_0x%04X_0x%04X.json", slug, device.VendorID, device.ProductID) } // renderNamesFile writes the file as a person edits it: one effect ID per line, // with an empty name to fill in. A generated file therefore has no names in it // and the board reports none, which is the state it was in before the file // existed. The keys are written in ID order because that is the order a person // works through them in, and the channels in channel order for the same reason. func renderNamesFile(name string, device intdevice.Device, slots map[via.Channel][]uint16) ([]byte, error) { var b strings.Builder fmt.Fprintf(&b, "{\n") // The kind is what tells a names file apart from a definition file. Both are // JSON, both carry the board's identifiers, and a names file in a definitions // directory would otherwise parse as a definition and take the board's place. fmt.Fprintf(&b, " \"kind\": %q,\n", intrgb.NamesFileKind) fmt.Fprintf(&b, " \"name\": %q,\n", name) fmt.Fprintf(&b, " \"vendorId\": \"0x%04X\",\n", device.VendorID) fmt.Fprintf(&b, " \"productId\": \"0x%04X\",\n", device.ProductID) fmt.Fprintf(&b, " \"channels\": {\n") written := 0 for _, ch := range via.LightingChannels { ids, ok := slots[ch] if !ok || len(ids) == 0 { continue } if written > 0 { fmt.Fprintf(&b, ",\n") } written++ fmt.Fprintf(&b, " %q: {\n", ch.Subsystem()) for i, id := range ids { comma := "," if i == len(ids)-1 { comma = "" } fmt.Fprintf(&b, " \"%d\": \"%s\"%s\n", id, "", comma) } fmt.Fprintf(&b, " }") } fmt.Fprintf(&b, "\n }\n}\n") return []byte(b.String()), nil }