package rgb import ( "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strconv" "strings" "netdome.biz/paul/qmk-rgb/internal/via" ) // DefinitionsDir is the name of the directory definition files are kept in. It // is a name and not a location: the tool resolves where that directory is, and // where it is written to when created, in definitionsPath. The keyboard is // identified first, so a file is only ever read for a board whose vendor and // product ID match. const DefinitionsDir = "definitions" // VendorProductID is the key a definition is filed under, and the key the // keyboard is looked up with: vendorID * 65536 + productID. The multiplication // rather than a shift is what VIA uses, and the two must stay equal. func VendorProductID(vendorID, productID uint16) int64 { return int64(vendorID)*65536 + int64(productID) } // effectValueKeys are the value keys whose control is a list of effects. A // definition that invents its own key for effects does not name them, because // nothing distinguishes such a control from any other dropdown. var effectValueKeys = map[string]via.Channel{ "id_qmk_backlight_effect": via.ChannelBacklight, "id_qmk_rgblight_effect": via.ChannelRgblight, "id_qmk_rgb_matrix_effect": via.ChannelRgbMatrix, "id_qmk_audio_effect": via.ChannelAudio, "id_qmk_led_matrix_effect": via.ChannelLedMatrix, } // Definition is one keyboard definition file: the board it describes, and the // effect names it holds per lighting channel. type Definition struct { Path string Name string VendorID uint16 ProductID uint16 Catalog *Catalog // Labels is the name each lighting channel has in VIA's own interface, taken // from the sub-menu the definition puts it under. It is board data the file // already carries, and using it means the tool and VIA call the same channel // the same thing instead of the tool adding a third name of its own. Labels map[uint16]string } // ErrNotADefinition is returned for a file that is deliberately a different kind // of document — a names file, which this tool also keeps as JSON. It is separate // from a parse failure because the two call for opposite answers: a broken // definition is a file the user has to fix, and a names file in the wrong // directory is a file to skip or move. var ErrNotADefinition = errors.New("not a VIA definition") // NamesFileKind is the value the "kind" field of a names file carries. A file // declaring it is not a definition, whatever its name and wherever it sits, which // is the only reliable way to tell the two apart: a names file has the board's // identifiers and no menus, so it parses as a definition and would otherwise take // the board's place. const NamesFileKind = "names" // definitionFile is the part of a VIA definition this tool reads. VIA serves // built definitions that carry vendorProductId as a number and no vendorId and // productId pair, so both spellings are optional and at least one is required. type definitionFile struct { Kind string `json:"kind"` Name string `json:"name"` VendorID string `json:"vendorId"` ProductID string `json:"productId"` VendorProductID int64 `json:"vendorProductId"` Menus json.RawMessage `json:"menus"` } // Matches reports whether a definition is the one for a board. A definition // for another board must not be used for this one, however it was found. func (d *Definition) Matches(vendorID, productID uint16) bool { return d.VendorID == vendorID && d.ProductID == productID } // LoadDefinition reads one definition file. func LoadDefinition(path string) (*Definition, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read %s: %w", path, err) } def, err := ParseDefinition(path, data) if err != nil { return nil, err } return def, nil } // ParseDefinition reads a definition from bytes, and is what LoadDefinition // hands them to. A file fetched from a server is not a file on disk, and this // is also the check that rejects a page which is not a definition: VIA answers // an unknown board with its own web page and a success status, so a fetched // document has to be parsed and matched before it is believed. func ParseDefinition(source string, data []byte) (*Definition, error) { var file definitionFile if err := json.Unmarshal(data, &file); err != nil { return nil, fmt.Errorf("parse %s: not a keyboard definition: %w", source, err) } if file.Kind == NamesFileKind { return nil, fmt.Errorf("parse %s: %w, it is a names file (kind %q)", source, ErrNotADefinition, NamesFileKind) } vendorID, productID, err := file.identifiers() if err != nil { return nil, fmt.Errorf("%s: %w", source, err) } board := file.Name if board == "" { board = filepath.Base(source) } return &Definition{ Path: source, Name: board, VendorID: vendorID, ProductID: productID, Catalog: NewCatalog(board, parseEffects(file.Menus)), Labels: parseLabels(file.Menus), }, nil } // lightingValueKeys are the value keys a lighting channel binds. A control on one // of them says which channel the sub-menu around it is about. var lightingValueKeys = map[string]bool{ "id_qmk_backlight_brightness": true, "id_qmk_backlight_effect": true, "id_qmk_rgblight_brightness": true, "id_qmk_rgblight_effect": true, "id_qmk_rgblight_effect_speed": true, "id_qmk_rgblight_color": true, "id_qmk_rgb_matrix_brightness": true, "id_qmk_rgb_matrix_effect": true, "id_qmk_rgb_matrix_effect_speed": true, "id_qmk_rgb_matrix_color": true, "id_qmk_audio_brightness": true, "id_qmk_audio_effect": true, "id_qmk_audio_effect_speed": true, "id_qmk_audio_color": true, "id_qmk_led_matrix_brightness": true, "id_qmk_led_matrix_effect": true, "id_qmk_led_matrix_color": true, } // parseLabels returns the sub-menu label per lighting channel: the name VIA // shows for that channel. A definition that names no channel yields nothing, so // the name a keyboard has elsewhere stays in charge. func parseLabels(menus json.RawMessage) map[uint16]string { if len(menus) == 0 { return nil } var decoded any if err := json.Unmarshal(menus, &decoded); err != nil { return nil } labels := make(map[uint16]string) var walk func(node any, submenu string) walk = func(node any, submenu string) { switch v := node.(type) { case []any: for _, child := range v { walk(child, submenu) } case map[string]any: label, _ := v["label"].(string) // A sub-menu is an object holding controls; a control is an object // holding a value binding. Which one this is tells us whether the // label around a control is the channel's name. if controls, ok := v["content"].([]any); ok && len(controls) > 0 { if _, isControl := controls[0].(map[string]any); isControl && label != "" { submenu = label } } if c, ok := v["content"].([]any); ok && len(c) >= 2 { if key, isString := c[0].(string); isString && lightingValueKeys[key] { if number, isNumber := c[1].(float64); isNumber && submenu != "" { labels[uint16(number)] = submenu } } } for _, child := range v { walk(child, submenu) } } } walk(decoded, "") if len(labels) == 0 { return nil } return labels } // identifiers returns the board a definition is for, from whichever of the two // spellings the file carries. func (f definitionFile) identifiers() (uint16, uint16, error) { if f.VendorProductID != 0 { return uint16(f.VendorProductID / 65536), uint16(f.VendorProductID % 65536), nil } if f.VendorID == "" || f.ProductID == "" { return 0, 0, fmt.Errorf("neither vendorProductId nor vendorId/productId; not a keyboard definition") } vendorID, err := parseHexID(f.VendorID) if err != nil { return 0, 0, fmt.Errorf("vendorId %q: %w", f.VendorID, err) } productID, err := parseHexID(f.ProductID) if err != nil { return 0, 0, fmt.Errorf("productId %q: %w", f.ProductID, err) } return vendorID, productID, nil } func parseHexID(s string) (uint16, error) { v, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimSpace(s), "0x"), 16, 16) if err != nil { return 0, fmt.Errorf("not a hexadecimal USB ID: %w", err) } return uint16(v), nil } // parseEffects walks the menus of a definition and returns the effect list of // every lighting channel it names. The walk is generic because a definition // nests its controls as it likes; what identifies an effect list is the value // key and an options array beside it. func parseEffects(menus json.RawMessage) map[via.Channel][]Effect { if len(menus) == 0 { return nil } var found map[via.Channel][]Effect var walk func(node any) walk = func(node any) { switch v := node.(type) { case map[string]any: if effects, ok := effectList(v); ok { if found == nil { found = make(map[via.Channel][]Effect) } found[effects.channel] = append(found[effects.channel], effects.list...) } for _, child := range v { walk(child) } case []any: for _, child := range v { walk(child) } } } var decoded any if err := json.Unmarshal(menus, &decoded); err != nil { return nil } walk(decoded) return found } type channelEffects struct { channel via.Channel list []Effect } // effectList reads one UI control and reports the effects it offers, if it is an // effect list at all. func effectList(control map[string]any) (channelEffects, bool) { content, ok := control["content"].([]any) if !ok || len(content) < 2 { return channelEffects{}, false } valueKey, ok := content[0].(string) if !ok { return channelEffects{}, false } if _, known := effectValueKeys[valueKey]; !known { return channelEffects{}, false } channelNumber, ok := content[1].(float64) if !ok { return channelEffects{}, false } options, ok := control["options"].([]any) if !ok { return channelEffects{}, false } out := channelEffects{channel: via.Channel(int(channelNumber))} for position, option := range options { name, id, hasID, ok := optionName(option) if !ok { // An option that is neither a string nor a name/number pair has // 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) } out.list = append(out.list, Effect{ID: id, Name: name}) } if len(out.list) == 0 { return channelEffects{}, false } sort.Slice(out.list, func(i, j int) bool { return out.list[i].ID < out.list[j].ID }) return out, true } // optionName reads one dropdown option. VIA allows a bare string, which takes // its number from the position, or a name and number pair, whose number is the // value and need not be the position. The second is why an index into a name // list is not an effect ID. func optionName(option any) (name string, id uint8, hasID bool, ok bool) { switch v := option.(type) { case string: return v, 0, false, true case []any: if len(v) == 0 { return "", 0, false, false } label, isString := v[0].(string) if !isString { return "", 0, false, false } if len(v) < 2 { return label, 0, false, true } number, isNumber := v[1].(float64) if !isNumber { return label, 0, false, true } return label, uint8(number), true, true default: return "", 0, false, false } } // LoadDefinitionsDir reads every definition file in a directory. A file it // cannot use is an error rather than a silent skip: a definition the user // placed there and that does not work is worth saying out loud. func LoadDefinitionsDir(dir string) ([]*Definition, error) { entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("read %s: %w", dir, err) } var defs []*Definition for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } def, err := LoadDefinition(filepath.Join(dir, entry.Name())) if errors.Is(err, ErrNotADefinition) { // A names file in the definitions directory is not a definition that // failed to parse, it is a different kind of document, and it must not // be read as one. It carries vendorId and productId and no menus, so // without this it parses and shadows the board's real file. continue } if err != nil { return nil, err } defs = append(defs, def) } sort.Slice(defs, func(i, j int) bool { return defs[i].Path < defs[j].Path }) return defs, nil } // LoadDefinitionsFS reads every definition file in a filesystem, which is what a // directory read and the set built into the binary have in common. The path is // only used to name the files in errors, so a caller that has an embed.FS can // pass the directory it presents itself as. // // A file it cannot use is an error rather than a silent skip, for the same // reason as in a directory: a definition that does not work is worth saying out // loud. A vendored file that fails this is a build defect, not a user's file, so // the caller decides what an error here means. func LoadDefinitionsFS(fsys fs.FS, dir string) ([]*Definition, error) { entries, err := fs.ReadDir(fsys, ".") if err != nil { return nil, fmt.Errorf("read %s: %w", dir, err) } var defs []*Definition for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } data, err := fs.ReadFile(fsys, entry.Name()) if err != nil { return nil, fmt.Errorf("read %s: %w", filepath.Join(dir, entry.Name()), err) } def, err := ParseDefinition(filepath.Join(dir, entry.Name()), data) if err != nil { return nil, err } defs = append(defs, def) } sort.Slice(defs, func(i, j int) bool { return defs[i].Path < defs[j].Path }) return defs, nil } // FindDefinition returns the definition for a board, or nil when none of the // definitions is for it. The first match wins, so the order the definitions are // passed in is the order the tool trusts: a file in a directory a user controls // before one built into the binary. func FindDefinition(defs []*Definition, vendorID, productID uint16) *Definition { for _, def := range defs { if def.VendorID == vendorID && def.ProductID == productID { return def } } 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 }