definition.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. package rgb
  2. import (
  3. "encoding/json"
  4. "errors"
  5. "fmt"
  6. "io/fs"
  7. "os"
  8. "path/filepath"
  9. "sort"
  10. "strconv"
  11. "strings"
  12. "netdome.biz/paul/qmk-rgb/internal/via"
  13. )
  14. // DefinitionsDir is the name of the directory definition files are kept in. It
  15. // is a name and not a location: the tool resolves where that directory is, and
  16. // where it is written to when created, in definitionsPath. The keyboard is
  17. // identified first, so a file is only ever read for a board whose vendor and
  18. // product ID match.
  19. const DefinitionsDir = "definitions"
  20. // VendorProductID is the key a definition is filed under, and the key the
  21. // keyboard is looked up with: vendorID * 65536 + productID. The multiplication
  22. // rather than a shift is what VIA uses, and the two must stay equal.
  23. func VendorProductID(vendorID, productID uint16) int64 {
  24. return int64(vendorID)*65536 + int64(productID)
  25. }
  26. // effectValueKeys are the value keys whose control is a list of effects. A
  27. // definition that invents its own key for effects does not name them, because
  28. // nothing distinguishes such a control from any other dropdown.
  29. var effectValueKeys = map[string]via.Channel{
  30. "id_qmk_backlight_effect": via.ChannelBacklight,
  31. "id_qmk_rgblight_effect": via.ChannelRgblight,
  32. "id_qmk_rgb_matrix_effect": via.ChannelRgbMatrix,
  33. "id_qmk_audio_effect": via.ChannelAudio,
  34. "id_qmk_led_matrix_effect": via.ChannelLedMatrix,
  35. }
  36. // Definition is one keyboard definition file: the board it describes, and the
  37. // effect names it holds per lighting channel.
  38. type Definition struct {
  39. Path string
  40. Name string
  41. VendorID uint16
  42. ProductID uint16
  43. Catalog *Catalog
  44. // Labels is the name each lighting channel has in VIA's own interface, taken
  45. // from the sub-menu the definition puts it under. It is board data the file
  46. // already carries, and using it means the tool and VIA call the same channel
  47. // the same thing instead of the tool adding a third name of its own.
  48. Labels map[uint16]string
  49. }
  50. // ErrNotADefinition is returned for a file that is deliberately a different kind
  51. // of document — a names file, which this tool also keeps as JSON. It is separate
  52. // from a parse failure because the two call for opposite answers: a broken
  53. // definition is a file the user has to fix, and a names file in the wrong
  54. // directory is a file to skip or move.
  55. var ErrNotADefinition = errors.New("not a VIA definition")
  56. // NamesFileKind is the value the "kind" field of a names file carries. A file
  57. // declaring it is not a definition, whatever its name and wherever it sits, which
  58. // is the only reliable way to tell the two apart: a names file has the board's
  59. // identifiers and no menus, so it parses as a definition and would otherwise take
  60. // the board's place.
  61. const NamesFileKind = "names"
  62. // definitionFile is the part of a VIA definition this tool reads. VIA serves
  63. // built definitions that carry vendorProductId as a number and no vendorId and
  64. // productId pair, so both spellings are optional and at least one is required.
  65. type definitionFile struct {
  66. Kind string `json:"kind"`
  67. Name string `json:"name"`
  68. VendorID string `json:"vendorId"`
  69. ProductID string `json:"productId"`
  70. VendorProductID int64 `json:"vendorProductId"`
  71. Menus json.RawMessage `json:"menus"`
  72. }
  73. // Matches reports whether a definition is the one for a board. A definition
  74. // for another board must not be used for this one, however it was found.
  75. func (d *Definition) Matches(vendorID, productID uint16) bool {
  76. return d.VendorID == vendorID && d.ProductID == productID
  77. }
  78. // LoadDefinition reads one definition file.
  79. func LoadDefinition(path string) (*Definition, error) {
  80. data, err := os.ReadFile(path)
  81. if err != nil {
  82. return nil, fmt.Errorf("read %s: %w", path, err)
  83. }
  84. def, err := ParseDefinition(path, data)
  85. if err != nil {
  86. return nil, err
  87. }
  88. return def, nil
  89. }
  90. // ParseDefinition reads a definition from bytes, and is what LoadDefinition
  91. // hands them to. A file fetched from a server is not a file on disk, and this
  92. // is also the check that rejects a page which is not a definition: VIA answers
  93. // an unknown board with its own web page and a success status, so a fetched
  94. // document has to be parsed and matched before it is believed.
  95. func ParseDefinition(source string, data []byte) (*Definition, error) {
  96. var file definitionFile
  97. if err := json.Unmarshal(data, &file); err != nil {
  98. return nil, fmt.Errorf("parse %s: not a keyboard definition: %w", source, err)
  99. }
  100. if file.Kind == NamesFileKind {
  101. return nil, fmt.Errorf("parse %s: %w, it is a names file (kind %q)", source, ErrNotADefinition, NamesFileKind)
  102. }
  103. vendorID, productID, err := file.identifiers()
  104. if err != nil {
  105. return nil, fmt.Errorf("%s: %w", source, err)
  106. }
  107. board := file.Name
  108. if board == "" {
  109. board = filepath.Base(source)
  110. }
  111. return &Definition{
  112. Path: source,
  113. Name: board,
  114. VendorID: vendorID,
  115. ProductID: productID,
  116. Catalog: NewCatalog(board, parseEffects(file.Menus)),
  117. Labels: parseLabels(file.Menus),
  118. }, nil
  119. }
  120. // lightingValueKeys are the value keys a lighting channel binds. A control on one
  121. // of them says which channel the sub-menu around it is about.
  122. var lightingValueKeys = map[string]bool{
  123. "id_qmk_backlight_brightness": true,
  124. "id_qmk_backlight_effect": true,
  125. "id_qmk_rgblight_brightness": true,
  126. "id_qmk_rgblight_effect": true,
  127. "id_qmk_rgblight_effect_speed": true,
  128. "id_qmk_rgblight_color": true,
  129. "id_qmk_rgb_matrix_brightness": true,
  130. "id_qmk_rgb_matrix_effect": true,
  131. "id_qmk_rgb_matrix_effect_speed": true,
  132. "id_qmk_rgb_matrix_color": true,
  133. "id_qmk_audio_brightness": true,
  134. "id_qmk_audio_effect": true,
  135. "id_qmk_audio_effect_speed": true,
  136. "id_qmk_audio_color": true,
  137. "id_qmk_led_matrix_brightness": true,
  138. "id_qmk_led_matrix_effect": true,
  139. "id_qmk_led_matrix_color": true,
  140. }
  141. // parseLabels returns the sub-menu label per lighting channel: the name VIA
  142. // shows for that channel. A definition that names no channel yields nothing, so
  143. // the name a keyboard has elsewhere stays in charge.
  144. func parseLabels(menus json.RawMessage) map[uint16]string {
  145. if len(menus) == 0 {
  146. return nil
  147. }
  148. var decoded any
  149. if err := json.Unmarshal(menus, &decoded); err != nil {
  150. return nil
  151. }
  152. labels := make(map[uint16]string)
  153. var walk func(node any, submenu string)
  154. walk = func(node any, submenu string) {
  155. switch v := node.(type) {
  156. case []any:
  157. for _, child := range v {
  158. walk(child, submenu)
  159. }
  160. case map[string]any:
  161. label, _ := v["label"].(string)
  162. // A sub-menu is an object holding controls; a control is an object
  163. // holding a value binding. Which one this is tells us whether the
  164. // label around a control is the channel's name.
  165. if controls, ok := v["content"].([]any); ok && len(controls) > 0 {
  166. if _, isControl := controls[0].(map[string]any); isControl && label != "" {
  167. submenu = label
  168. }
  169. }
  170. if c, ok := v["content"].([]any); ok && len(c) >= 2 {
  171. if key, isString := c[0].(string); isString && lightingValueKeys[key] {
  172. if number, isNumber := c[1].(float64); isNumber && submenu != "" {
  173. labels[uint16(number)] = submenu
  174. }
  175. }
  176. }
  177. for _, child := range v {
  178. walk(child, submenu)
  179. }
  180. }
  181. }
  182. walk(decoded, "")
  183. if len(labels) == 0 {
  184. return nil
  185. }
  186. return labels
  187. }
  188. // identifiers returns the board a definition is for, from whichever of the two
  189. // spellings the file carries.
  190. func (f definitionFile) identifiers() (uint16, uint16, error) {
  191. if f.VendorProductID != 0 {
  192. return uint16(f.VendorProductID / 65536), uint16(f.VendorProductID % 65536), nil
  193. }
  194. if f.VendorID == "" || f.ProductID == "" {
  195. return 0, 0, fmt.Errorf("neither vendorProductId nor vendorId/productId; not a keyboard definition")
  196. }
  197. vendorID, err := parseHexID(f.VendorID)
  198. if err != nil {
  199. return 0, 0, fmt.Errorf("vendorId %q: %w", f.VendorID, err)
  200. }
  201. productID, err := parseHexID(f.ProductID)
  202. if err != nil {
  203. return 0, 0, fmt.Errorf("productId %q: %w", f.ProductID, err)
  204. }
  205. return vendorID, productID, nil
  206. }
  207. func parseHexID(s string) (uint16, error) {
  208. v, err := strconv.ParseUint(strings.TrimPrefix(strings.TrimSpace(s), "0x"), 16, 16)
  209. if err != nil {
  210. return 0, fmt.Errorf("not a hexadecimal USB ID: %w", err)
  211. }
  212. return uint16(v), nil
  213. }
  214. // parseEffects walks the menus of a definition and returns the effect list of
  215. // every lighting channel it names. The walk is generic because a definition
  216. // nests its controls as it likes; what identifies an effect list is the value
  217. // key and an options array beside it.
  218. func parseEffects(menus json.RawMessage) map[via.Channel][]Effect {
  219. if len(menus) == 0 {
  220. return nil
  221. }
  222. var found map[via.Channel][]Effect
  223. var walk func(node any)
  224. walk = func(node any) {
  225. switch v := node.(type) {
  226. case map[string]any:
  227. if effects, ok := effectList(v); ok {
  228. if found == nil {
  229. found = make(map[via.Channel][]Effect)
  230. }
  231. found[effects.channel] = append(found[effects.channel], effects.list...)
  232. }
  233. for _, child := range v {
  234. walk(child)
  235. }
  236. case []any:
  237. for _, child := range v {
  238. walk(child)
  239. }
  240. }
  241. }
  242. var decoded any
  243. if err := json.Unmarshal(menus, &decoded); err != nil {
  244. return nil
  245. }
  246. walk(decoded)
  247. return found
  248. }
  249. type channelEffects struct {
  250. channel via.Channel
  251. list []Effect
  252. }
  253. // effectList reads one UI control and reports the effects it offers, if it is an
  254. // effect list at all.
  255. func effectList(control map[string]any) (channelEffects, bool) {
  256. content, ok := control["content"].([]any)
  257. if !ok || len(content) < 2 {
  258. return channelEffects{}, false
  259. }
  260. valueKey, ok := content[0].(string)
  261. if !ok {
  262. return channelEffects{}, false
  263. }
  264. if _, known := effectValueKeys[valueKey]; !known {
  265. return channelEffects{}, false
  266. }
  267. channelNumber, ok := content[1].(float64)
  268. if !ok {
  269. return channelEffects{}, false
  270. }
  271. options, ok := control["options"].([]any)
  272. if !ok {
  273. return channelEffects{}, false
  274. }
  275. out := channelEffects{channel: via.Channel(int(channelNumber))}
  276. for position, option := range options {
  277. name, id, hasID, ok := optionName(option)
  278. if !ok {
  279. // An option that is neither a string nor a name/number pair has
  280. // no name to report; skipping it keeps the rest of the list.
  281. continue
  282. }
  283. if strings.TrimSpace(name) == "" {
  284. // An option with an empty name states a slot without naming it. That
  285. // is what a scaffold a user is meant to fill in looks like, and a
  286. // board that has one is not a board with an effect called "": the
  287. // slot is known and the name is not, so the channel reports no
  288. // effects until the name is there.
  289. continue
  290. }
  291. if !hasID {
  292. id = uint8(position)
  293. }
  294. out.list = append(out.list, Effect{ID: id, Name: name})
  295. }
  296. if len(out.list) == 0 {
  297. return channelEffects{}, false
  298. }
  299. sort.Slice(out.list, func(i, j int) bool { return out.list[i].ID < out.list[j].ID })
  300. return out, true
  301. }
  302. // optionName reads one dropdown option. VIA allows a bare string, which takes
  303. // its number from the position, or a name and number pair, whose number is the
  304. // value and need not be the position. The second is why an index into a name
  305. // list is not an effect ID.
  306. func optionName(option any) (name string, id uint8, hasID bool, ok bool) {
  307. switch v := option.(type) {
  308. case string:
  309. return v, 0, false, true
  310. case []any:
  311. if len(v) == 0 {
  312. return "", 0, false, false
  313. }
  314. label, isString := v[0].(string)
  315. if !isString {
  316. return "", 0, false, false
  317. }
  318. if len(v) < 2 {
  319. return label, 0, false, true
  320. }
  321. number, isNumber := v[1].(float64)
  322. if !isNumber {
  323. return label, 0, false, true
  324. }
  325. return label, uint8(number), true, true
  326. default:
  327. return "", 0, false, false
  328. }
  329. }
  330. // LoadDefinitionsDir reads every definition file in a directory. A file it
  331. // cannot use is an error rather than a silent skip: a definition the user
  332. // placed there and that does not work is worth saying out loud.
  333. func LoadDefinitionsDir(dir string) ([]*Definition, error) {
  334. entries, err := os.ReadDir(dir)
  335. if err != nil {
  336. return nil, fmt.Errorf("read %s: %w", dir, err)
  337. }
  338. var defs []*Definition
  339. for _, entry := range entries {
  340. if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
  341. continue
  342. }
  343. def, err := LoadDefinition(filepath.Join(dir, entry.Name()))
  344. if errors.Is(err, ErrNotADefinition) {
  345. // A names file in the definitions directory is not a definition that
  346. // failed to parse, it is a different kind of document, and it must not
  347. // be read as one. It carries vendorId and productId and no menus, so
  348. // without this it parses and shadows the board's real file.
  349. continue
  350. }
  351. if err != nil {
  352. return nil, err
  353. }
  354. defs = append(defs, def)
  355. }
  356. sort.Slice(defs, func(i, j int) bool { return defs[i].Path < defs[j].Path })
  357. return defs, nil
  358. }
  359. // LoadDefinitionsFS reads every definition file in a filesystem, which is what a
  360. // directory read and the set built into the binary have in common. The path is
  361. // only used to name the files in errors, so a caller that has an embed.FS can
  362. // pass the directory it presents itself as.
  363. //
  364. // A file it cannot use is an error rather than a silent skip, for the same
  365. // reason as in a directory: a definition that does not work is worth saying out
  366. // loud. A vendored file that fails this is a build defect, not a user's file, so
  367. // the caller decides what an error here means.
  368. func LoadDefinitionsFS(fsys fs.FS, dir string) ([]*Definition, error) {
  369. entries, err := fs.ReadDir(fsys, ".")
  370. if err != nil {
  371. return nil, fmt.Errorf("read %s: %w", dir, err)
  372. }
  373. var defs []*Definition
  374. for _, entry := range entries {
  375. if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
  376. continue
  377. }
  378. data, err := fs.ReadFile(fsys, entry.Name())
  379. if err != nil {
  380. return nil, fmt.Errorf("read %s: %w", filepath.Join(dir, entry.Name()), err)
  381. }
  382. def, err := ParseDefinition(filepath.Join(dir, entry.Name()), data)
  383. if err != nil {
  384. return nil, err
  385. }
  386. defs = append(defs, def)
  387. }
  388. sort.Slice(defs, func(i, j int) bool { return defs[i].Path < defs[j].Path })
  389. return defs, nil
  390. }
  391. // FindDefinition returns the definition for a board, or nil when none of the
  392. // definitions is for it. The first match wins, so the order the definitions are
  393. // passed in is the order the tool trusts: a file in a directory a user controls
  394. // before one built into the binary.
  395. func FindDefinition(defs []*Definition, vendorID, productID uint16) *Definition {
  396. for _, def := range defs {
  397. if def.VendorID == vendorID && def.ProductID == productID {
  398. return def
  399. }
  400. }
  401. return nil
  402. }
  403. // EffectValueKey returns the VIA value key a channel's effect list is filed
  404. // under, and whether one is registered for it. The lookup goes through
  405. // effectValueKeys rather than a second table, because that map is where a value
  406. // key is registered and a second one would fall behind it.
  407. func EffectValueKey(ch via.Channel) (string, bool) {
  408. for key, channel := range effectValueKeys {
  409. if channel == ch {
  410. return key, true
  411. }
  412. }
  413. return "", false
  414. }