profile.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io"
  6. "os"
  7. "path/filepath"
  8. "sort"
  9. "strings"
  10. "github.com/spf13/cobra"
  11. intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
  12. "netdome.biz/paul/qmk-rgb/internal/via"
  13. )
  14. type Profile struct {
  15. Name string `json:"name"`
  16. Version int `json:"version"`
  17. Board *Board `json:"board,omitempty"`
  18. Zones map[string]*ZoneSettings `json:"zones"`
  19. }
  20. // Board identifies the keyboard a profile was saved from. A profile stores
  21. // effect names, and names belong to a board, so without this a profile written
  22. // for one keyboard would be applied to another without a word. The pair is
  23. // spelled as hex strings, the way the VIA definition files
  24. // spell it, so the three can be read side by side.
  25. type Board struct {
  26. VendorID string `json:"vendorId"`
  27. ProductID string `json:"productId"`
  28. }
  29. // boardMatch is what comparing a profile's board to the connected one tells us.
  30. type boardMatch int
  31. const (
  32. // boardUnknown is a profile written before profiles carried a board.
  33. boardUnknown boardMatch = iota
  34. boardSame
  35. boardOther
  36. )
  37. func boardFor(vendorID, productID uint16) *Board {
  38. return &Board{
  39. VendorID: fmt.Sprintf("0x%04X", vendorID),
  40. ProductID: fmt.Sprintf("0x%04X", productID),
  41. }
  42. }
  43. // MatchesBoard reports whether a profile was saved from the connected keyboard,
  44. // was saved from another one, or says nothing about it.
  45. func (p *Profile) MatchesBoard(vendorID, productID uint16) boardMatch {
  46. if p == nil || p.Board == nil {
  47. return boardUnknown
  48. }
  49. if strings.EqualFold(p.Board.VendorID, fmt.Sprintf("0x%04X", vendorID)) &&
  50. strings.EqualFold(p.Board.ProductID, fmt.Sprintf("0x%04X", productID)) {
  51. return boardSame
  52. }
  53. return boardOther
  54. }
  55. // boardMismatchWarning says which profile belongs to which keyboard, because
  56. // the user has to be able to tell which of their profiles is the wrong one.
  57. func (p *Profile) boardMismatchWarning(vendorID, productID uint16) string {
  58. return fmt.Sprintf("Warning: profile %q was saved for keyboard %s/%s, and this keyboard is %s/%s; "+
  59. "its effect names may not exist here\n",
  60. p.Name, p.Board.VendorID, p.Board.ProductID,
  61. fmt.Sprintf("0x%04X", vendorID), fmt.Sprintf("0x%04X", productID))
  62. }
  63. // listLine is one profile as the list command prints it.
  64. func (p *Profile) listLine() string {
  65. if p.Board == nil {
  66. return p.Name
  67. }
  68. return fmt.Sprintf("%s %s/%s", p.Name, p.Board.VendorID, p.Board.ProductID)
  69. }
  70. type ZoneSettings struct {
  71. Enabled bool `json:"enabled"`
  72. Effect string `json:"effect"`
  73. Brightness uint8 `json:"brightness"`
  74. Speed uint8 `json:"speed"`
  75. Color string `json:"color"`
  76. }
  77. // ProfilesPath is the directory the profiles live in.
  78. func ProfilesPath() string {
  79. return profilesPath()
  80. }
  81. // profileFileName is the file a name maps to, without a directory.
  82. func profileFileName(name string) string {
  83. return sanitizeFilename(name) + ".json"
  84. }
  85. func (p *Profile) Save() error {
  86. if p.Name == "" {
  87. return fmt.Errorf("profile name is required")
  88. }
  89. if err := os.MkdirAll(profilesPath(), 0o755); err != nil {
  90. return fmt.Errorf("create profiles directory: %w", err)
  91. }
  92. data, err := json.MarshalIndent(p, "", " ")
  93. if err != nil {
  94. return fmt.Errorf("marshal profile: %w", err)
  95. }
  96. path := filepath.Join(profilesPath(), profileFileName(p.Name))
  97. if err := os.WriteFile(path, data, 0644); err != nil {
  98. return fmt.Errorf("write profile: %w", err)
  99. }
  100. return nil
  101. }
  102. func LoadProfile(name string) (*Profile, error) {
  103. path := filepath.Join(profilesPath(), profileFileName(name))
  104. data, err := os.ReadFile(path)
  105. if err != nil {
  106. if os.IsNotExist(err) {
  107. return nil, fmt.Errorf("profile %s not found in %s", name, profilesPath())
  108. }
  109. return nil, fmt.Errorf("read profile %s: %w", name, err)
  110. }
  111. var p Profile
  112. if err := json.Unmarshal(data, &p); err != nil {
  113. return nil, fmt.Errorf("parse profile %s: %w", name, err)
  114. }
  115. return &p, nil
  116. }
  117. func ListProfiles() ([]string, error) {
  118. entries, err := os.ReadDir(profilesPath())
  119. if err != nil {
  120. if os.IsNotExist(err) {
  121. return nil, nil
  122. }
  123. return nil, fmt.Errorf("list profiles: %w", err)
  124. }
  125. var names []string
  126. for _, entry := range entries {
  127. if entry.IsDir() {
  128. continue
  129. }
  130. name := entry.Name()
  131. if !strings.HasSuffix(name, ".json") {
  132. continue
  133. }
  134. names = append(names, strings.TrimSuffix(name, ".json"))
  135. }
  136. return names, nil
  137. }
  138. func DeleteProfile(name string) error {
  139. path := filepath.Join(profilesPath(), profileFileName(name))
  140. if err := os.Remove(path); err != nil {
  141. if os.IsNotExist(err) {
  142. return fmt.Errorf("profile %s not found in %s", name, profilesPath())
  143. }
  144. return fmt.Errorf("delete profile %s: %w", name, err)
  145. }
  146. return nil
  147. }
  148. func sanitizeFilename(name string) string {
  149. name = strings.ToLower(name)
  150. name = strings.Map(func(r rune) rune {
  151. if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
  152. return r
  153. }
  154. return '-'
  155. }, name)
  156. if len(name) > 0 && name[0] == '-' {
  157. name = "unnamed-" + name
  158. }
  159. return name
  160. }
  161. // applyProfile reads RGB state from the device and stores it in a Profile.
  162. func applyProfileToProfile(proto rgbProtocol, channels []via.Channel, display map[uint16]string, catalog *intrgb.Catalog, p *Profile) error {
  163. out, err := readInfo(proto, channels, display, catalog)
  164. if err != nil {
  165. return fmt.Errorf("read device state: %w", err)
  166. }
  167. p.Zones = make(map[string]*ZoneSettings)
  168. for _, zi := range out.Zones {
  169. if zi.Error != "" {
  170. continue
  171. }
  172. p.Zones[zi.Zone] = &ZoneSettings{
  173. Enabled: zi.Enabled,
  174. Effect: zi.Effect,
  175. Brightness: zi.Brightness,
  176. Speed: zi.Speed,
  177. Color: fmt.Sprintf("%02x%02x", zi.Color.Hue, zi.Color.Saturation),
  178. }
  179. }
  180. return nil
  181. }
  182. // loadProfileFromDevice reads RGB state from device and saves it. Every channel
  183. // is recorded, so the profile a `load` applies later is the whole keyboard and
  184. // not the part of it that happened to be named. Warnings go to warn, which is the
  185. // command's stderr.
  186. func loadProfileFromDevice(name string, warn io.Writer) error {
  187. proto, target, channels, err := openTarget("")
  188. if err != nil {
  189. return err
  190. }
  191. defer proto.Close()
  192. catalog, _, err := resolveCatalog(target)
  193. if err != nil {
  194. return err
  195. }
  196. if catalog == nil {
  197. // The profile stores effect names and a board without a catalog has none
  198. // to store, so every channel is written as "unknown" and cannot be
  199. // restored. Say so here, where the user can still act on it. The way out
  200. // names the zone, because the command takes it as its first argument and
  201. // an index on its own would be read as a channel name.
  202. fmt.Fprintf(warn,
  203. "Warning: this keyboard has no effect names, so the profile records effect %q and cannot restore it; "+
  204. "run `keyboard fetch` for its VIA definition, or set an effect with `effect <zone> <index>`\n", "unknown")
  205. }
  206. p := &Profile{
  207. Name: name,
  208. Version: 1,
  209. Board: boardFor(target.Device.VendorID, target.Device.ProductID),
  210. }
  211. if err := applyProfileToProfile(proto, channels, target.Display, catalog, p); err != nil {
  212. return err
  213. }
  214. return p.Save()
  215. }
  216. func NewProfileSaveCmd() *cobra.Command {
  217. return &cobra.Command{
  218. Use: "save [name]",
  219. Short: "Save current RGB state to a profile",
  220. Long: "Read the current RGB settings from every channel of the keyboard and save\n" +
  221. "them as a JSON profile in the profiles/ directory.\n" +
  222. "\n" +
  223. "A profile is always complete. Recording only some of the channels would let a\n" +
  224. "later `load` apply them and leave the rest as they were, which reads as a\n" +
  225. "zone the profile had nothing to say about.",
  226. Args: cobra.MaximumNArgs(1),
  227. RunE: func(cmd *cobra.Command, args []string) error {
  228. name := "default"
  229. if len(args) > 0 {
  230. name = args[0]
  231. }
  232. return loadProfileFromDevice(name, cmd.ErrOrStderr())
  233. },
  234. }
  235. }
  236. func NewProfileLoadCmd() *cobra.Command {
  237. // Not withZoneArgs: the profile name comes first and the zone second, so
  238. // the zone completion only applies once the name has been typed.
  239. cmd := &cobra.Command{
  240. ValidArgsFunction: completeLoadArgs,
  241. Use: "load <name> [zone]",
  242. Short: "Load a profile and apply it to the keyboard",
  243. Long: "Read a JSON profile from the profiles/ directory and apply the saved RGB\n" +
  244. "settings to the keyboard. Without a zone the profile is applied to every\n" +
  245. "channel it names.",
  246. RunE: func(cmd *cobra.Command, args []string) error {
  247. name := args[0]
  248. zone := ""
  249. if len(args) == 2 {
  250. zone = args[1]
  251. }
  252. proto, target, selected, err := openTarget(zone)
  253. if err != nil {
  254. return err
  255. }
  256. defer proto.Close()
  257. p, err := LoadProfile(name)
  258. if err != nil {
  259. return err
  260. }
  261. // A profile belongs to the keyboard it was saved from: its effect
  262. // names are that board's. Applying it elsewhere is allowed, because
  263. // the names that do not exist are reported per key below, but it is
  264. // said out loud, since a mismatch is the likeliest reason.
  265. if p.MatchesBoard(target.Device.VendorID, target.Device.ProductID) == boardOther {
  266. fmt.Fprintln(cmd.ErrOrStderr(), p.boardMismatchWarning(target.Device.VendorID, target.Device.ProductID))
  267. }
  268. catalog, _, err := resolveCatalog(target)
  269. if err != nil {
  270. return err
  271. }
  272. keys := make([]string, 0, len(p.Zones))
  273. for key := range p.Zones {
  274. keys = append(keys, key)
  275. }
  276. sort.Strings(keys)
  277. // A key is a channel name as the file wrote it. Resolving it here
  278. // means a profile written before a board was renamed reports the
  279. // key it cannot place instead of silently applying nothing.
  280. selectedSet := make(map[via.Channel]bool, len(selected))
  281. for _, ch := range selected {
  282. selectedSet[ch] = true
  283. }
  284. // Presence and selection are two different questions. A key the
  285. // selection leaves out is the user's own choice and stays quiet; a
  286. // key naming a channel the keyboard does not have has nowhere to go
  287. // and is reported, whatever the selection says.
  288. present, err := proto.DetectChannels()
  289. if err != nil {
  290. return err
  291. }
  292. presentSet := make(map[via.Channel]bool, len(present))
  293. for _, ch := range present {
  294. presentSet[ch] = true
  295. }
  296. // Without a catalog there are no effect names to look the
  297. // profile's value up in, so the whole load is impossible. Say that
  298. // once instead of reporting every key's name as not found.
  299. if catalog == nil {
  300. fmt.Fprintf(cmd.ErrOrStderr(),
  301. "Warning: profile %q has no effect names for this keyboard, so nothing applied; "+
  302. "run `keyboard fetch` for its VIA definition\n", name)
  303. return nil
  304. }
  305. applied := 0
  306. for _, key := range keys {
  307. settings := p.Zones[key]
  308. keyChannels, err := resolveZoneName(key, target.Display, target.Alternatives)
  309. if err != nil {
  310. fmt.Fprintf(cmd.ErrOrStderr(),
  311. "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
  312. continue
  313. }
  314. placed := false
  315. for _, ch := range keyChannels {
  316. if presentSet[ch] {
  317. placed = true
  318. break
  319. }
  320. }
  321. if !placed {
  322. fmt.Fprintf(cmd.ErrOrStderr(),
  323. "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
  324. continue
  325. }
  326. for _, ch := range keyChannels {
  327. // A key is applied only where the selection allows it, so
  328. // `load <name> logo` leaves the other channels alone.
  329. if !selectedSet[ch] {
  330. continue
  331. }
  332. // A zone the caller named is an explicit request for it, so an
  333. // effect the channel does not have is said rather than skipped;
  334. // without one the whole profile is being applied and a key the
  335. // board cannot do is worth a warning instead of a refusal.
  336. targets, _, err := intrgb.ResolveEffect(catalog, settings.Effect, []via.Channel{ch}, zone != "")
  337. if err != nil {
  338. fmt.Fprintf(cmd.ErrOrStderr(),
  339. "Warning: effect %q not found on %s, skipping\n", settings.Effect, channelName(ch, target.Display))
  340. continue
  341. }
  342. // ResolveEffect can answer with nothing to do and no
  343. // error: the name exists on the board but not on this
  344. // channel, and no zone was named, so it is skipped. Indexing
  345. // its result would crash here instead of saying so.
  346. if len(targets) == 0 {
  347. fmt.Fprintf(cmd.ErrOrStderr(),
  348. "Warning: effect %q not found on %s, skipping\n", settings.Effect, channelName(ch, target.Display))
  349. continue
  350. }
  351. if err := proto.SetValue(ch, uint8(intrgb.EffectID), targets[0].ID); err != nil {
  352. return err
  353. }
  354. if err := proto.SetValue(ch, uint8(intrgb.Brightness), settings.Brightness); err != nil {
  355. return err
  356. }
  357. if err := proto.SetValue(ch, uint8(intrgb.Speed), settings.Speed); err != nil {
  358. return err
  359. }
  360. if settings.Enabled && settings.Color != "" {
  361. hue, sat, err := hexToHSV(settings.Color)
  362. if err != nil {
  363. fmt.Fprintf(cmd.ErrOrStderr(),
  364. "Warning: invalid color %q on zone %s, skipping\n", settings.Color, channelName(ch, target.Display))
  365. } else if err := proto.SetColor(ch, hue, sat); err != nil {
  366. return err
  367. }
  368. }
  369. applied++
  370. }
  371. }
  372. if applied == 0 {
  373. fmt.Fprintf(cmd.ErrOrStderr(),
  374. "Warning: profile %q has no settings for the selected zone(s); nothing applied\n", name)
  375. }
  376. return nil
  377. },
  378. }
  379. cmd.Args = zoneArgs(1, 2, "a profile name and at most a zone")
  380. return cmd
  381. }
  382. func NewProfileListCmd() *cobra.Command {
  383. return &cobra.Command{
  384. Use: "list",
  385. Short: "List saved profiles",
  386. Args: cobra.NoArgs,
  387. RunE: func(cmd *cobra.Command, args []string) error {
  388. names, err := ListProfiles()
  389. if err != nil {
  390. return err
  391. }
  392. if names == nil {
  393. names = []string{}
  394. }
  395. lines := make([]string, 0, len(names))
  396. for _, name := range names {
  397. p, err := LoadProfile(name)
  398. if err != nil {
  399. // A file that cannot be read is still a name in the
  400. // directory; the name is what the user can act on.
  401. lines = append(lines, name)
  402. continue
  403. }
  404. lines = append(lines, p.listLine())
  405. }
  406. if jsonOutput {
  407. return encodeJSON(cmd.OutOrStdout(), struct {
  408. Profiles []string `json:"profiles"`
  409. }{Profiles: names})
  410. }
  411. for _, line := range lines {
  412. fmt.Fprintln(cmd.OutOrStdout(), line)
  413. }
  414. return nil
  415. },
  416. }
  417. }
  418. func NewProfileDeleteCmd() *cobra.Command {
  419. var name string
  420. cmd := &cobra.Command{
  421. ValidArgsFunction: completeProfileNames,
  422. Use: "delete [name]",
  423. Short: "Delete a saved profile",
  424. Args: cobra.MaximumNArgs(1),
  425. RunE: func(cmd *cobra.Command, args []string) error {
  426. if len(args) == 0 {
  427. name = "default"
  428. } else {
  429. name = args[0]
  430. }
  431. return DeleteProfile(name)
  432. },
  433. }
  434. return cmd
  435. }
  436. func hexToHSV(s string) (uint8, uint8, error) {
  437. if len(s) != 4 {
  438. return 0, 0, fmt.Errorf("invalid HSV hex (expected 4 hex digits): %s", s)
  439. }
  440. var hue, sat uint8
  441. _, err := fmt.Sscanf(s, "%02x%02x", &hue, &sat)
  442. return hue, sat, err
  443. }