profile.go 14 KB

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