| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485 |
- 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
- }
|