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") } }