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