profile.go 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  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. Zones map[string]*ZoneSettings `json:"zones"`
  20. }
  21. type ZoneSettings struct {
  22. Enabled bool `json:"enabled"`
  23. Effect string `json:"effect"`
  24. Brightness uint8 `json:"brightness"`
  25. Speed uint8 `json:"speed"`
  26. Color string `json:"color"`
  27. }
  28. func ProfilesPath() string {
  29. return filepath.Join(profilesDir)
  30. }
  31. func (p *Profile) Save() error {
  32. if p.Name == "" {
  33. return fmt.Errorf("profile name is required")
  34. }
  35. if err := os.MkdirAll(profilesDir, 0755); err != nil {
  36. return fmt.Errorf("create profiles directory: %w", err)
  37. }
  38. data, err := json.MarshalIndent(p, "", " ")
  39. if err != nil {
  40. return fmt.Errorf("marshal profile: %w", err)
  41. }
  42. path := filepath.Join(profilesDir, sanitizeFilename(p.Name)+".json")
  43. if err := os.WriteFile(path, data, 0644); err != nil {
  44. return fmt.Errorf("write profile: %w", err)
  45. }
  46. return nil
  47. }
  48. func LoadProfile(name string) (*Profile, error) {
  49. path := filepath.Join(profilesDir, sanitizeFilename(name)+".json")
  50. data, err := os.ReadFile(path)
  51. if err != nil {
  52. return nil, fmt.Errorf("read profile %s: %w", name, err)
  53. }
  54. var p Profile
  55. if err := json.Unmarshal(data, &p); err != nil {
  56. return nil, fmt.Errorf("parse profile %s: %w", name, err)
  57. }
  58. return &p, nil
  59. }
  60. func ListProfiles() ([]string, error) {
  61. entries, err := os.ReadDir(profilesDir)
  62. if err != nil {
  63. if os.IsNotExist(err) {
  64. return nil, nil
  65. }
  66. return nil, fmt.Errorf("list profiles: %w", err)
  67. }
  68. var names []string
  69. for _, entry := range entries {
  70. if entry.IsDir() {
  71. continue
  72. }
  73. name := entry.Name()
  74. if strings.HasSuffix(name, ".json") {
  75. name = name[:len(name)-5]
  76. names = append(names, name)
  77. }
  78. }
  79. return names, nil
  80. }
  81. func DeleteProfile(name string) error {
  82. path := filepath.Join(profilesDir, sanitizeFilename(name)+".json")
  83. if err := os.Remove(path); err != nil {
  84. if os.IsNotExist(err) {
  85. return fmt.Errorf("profile %s not found", name)
  86. }
  87. return fmt.Errorf("delete profile %s: %w", name, err)
  88. }
  89. return nil
  90. }
  91. func sanitizeFilename(name string) string {
  92. name = strings.ToLower(name)
  93. name = strings.Map(func(r rune) rune {
  94. if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
  95. return r
  96. }
  97. return '-'
  98. }, name)
  99. if len(name) > 0 && name[0] == '-' {
  100. name = "unnamed-" + name
  101. }
  102. return name
  103. }
  104. // applyProfile reads RGB state from the device and stores it in a Profile.
  105. func applyProfileToProfile(proto rgbProtocol, channels []via.Channel, display map[uint16]string, catalog *intrgb.Catalog, p *Profile) error {
  106. out, err := readInfo(proto, channels, display, catalog)
  107. if err != nil {
  108. return fmt.Errorf("read device state: %w", err)
  109. }
  110. p.Zones = make(map[string]*ZoneSettings)
  111. for _, zi := range out.Zones {
  112. if zi.Error != "" {
  113. continue
  114. }
  115. p.Zones[zi.Zone] = &ZoneSettings{
  116. Enabled: zi.Enabled,
  117. Effect: zi.Effect,
  118. Brightness: zi.Brightness,
  119. Speed: zi.Speed,
  120. Color: fmt.Sprintf("%02x%02x", zi.Color.Hue, zi.Color.Saturation),
  121. }
  122. }
  123. return nil
  124. }
  125. // loadProfileFromDevice reads RGB state from device and saves it. Warnings go
  126. // to warn, which is the command's stderr.
  127. func loadProfileFromDevice(name string, warn io.Writer) error {
  128. proto, target, channels, err := openTarget()
  129. if err != nil {
  130. return err
  131. }
  132. defer proto.Close()
  133. catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
  134. if catalog == nil {
  135. // The profile stores effect names and a board without a catalog has none
  136. // to store, so every channel is written as "unknown" and cannot be
  137. // restored. Say so here, where the user can still act on it.
  138. fmt.Fprintf(warn,
  139. "Warning: this keyboard has no effect catalog, so the profile records effect %q and cannot restore it; set an effect with `mode <index>`\n", "unknown")
  140. }
  141. p := &Profile{
  142. Name: name,
  143. Version: 1,
  144. }
  145. if err := applyProfileToProfile(proto, channels, target.Display, catalog, p); err != nil {
  146. return err
  147. }
  148. return p.Save()
  149. }
  150. func NewProfileSaveCmd() *cobra.Command {
  151. var name string
  152. cmd := &cobra.Command{
  153. Use: "save [name]",
  154. Short: "Save current RGB state to a profile",
  155. Long: "Read the current RGB settings from the keyboard and save them as a JSON profile in the profiles/ directory.",
  156. Args: cobra.MaximumNArgs(1),
  157. RunE: func(cmd *cobra.Command, args []string) error {
  158. if len(args) == 0 {
  159. name = "default"
  160. } else {
  161. name = args[0]
  162. }
  163. return loadProfileFromDevice(name, cmd.ErrOrStderr())
  164. },
  165. }
  166. return cmd
  167. }
  168. func NewProfileLoadCmd() *cobra.Command {
  169. var name string
  170. cmd := &cobra.Command{
  171. Use: "load [name]",
  172. Short: "Load a profile and apply it to the keyboard",
  173. Long: "Read a JSON profile from the profiles/ directory and apply the saved RGB settings to the keyboard.",
  174. Args: cobra.MaximumNArgs(1),
  175. RunE: func(cmd *cobra.Command, args []string) error {
  176. if len(args) == 0 {
  177. name = "default"
  178. } else {
  179. name = args[0]
  180. }
  181. proto, target, selected, err := openTarget()
  182. if err != nil {
  183. return err
  184. }
  185. defer proto.Close()
  186. p, err := LoadProfile(name)
  187. if err != nil {
  188. return err
  189. }
  190. catalog, _ := intrgb.CatalogFor(target.Device.VendorID, target.Device.ProductID)
  191. keys := make([]string, 0, len(p.Zones))
  192. for key := range p.Zones {
  193. keys = append(keys, key)
  194. }
  195. sort.Strings(keys)
  196. // A key is a channel name as the file wrote it. Resolving it here
  197. // means a profile written before a board was renamed reports the
  198. // key it cannot place instead of silently applying nothing.
  199. selectedSet := make(map[via.Channel]bool, len(selected))
  200. for _, ch := range selected {
  201. selectedSet[ch] = true
  202. }
  203. // Presence and selection are two different questions. A key the
  204. // selection leaves out is the user's own choice and stays quiet; a
  205. // key naming a channel the keyboard does not have has nowhere to go
  206. // and is reported, whatever the selection says.
  207. present, err := proto.DetectChannels()
  208. if err != nil {
  209. return err
  210. }
  211. presentSet := make(map[via.Channel]bool, len(present))
  212. for _, ch := range present {
  213. presentSet[ch] = true
  214. }
  215. // Without a catalog there are no effect names to look the
  216. // profile's value up in, so the whole load is impossible. Say that
  217. // once instead of reporting every key's name as not found.
  218. if catalog == nil {
  219. fmt.Fprintf(cmd.ErrOrStderr(),
  220. "Warning: profile %q has no effect catalog for this keyboard; nothing applied\n", name)
  221. return nil
  222. }
  223. applied := 0
  224. for _, key := range keys {
  225. settings := p.Zones[key]
  226. keyChannels, err := resolveZoneName(key, target.Display)
  227. if err != nil {
  228. fmt.Fprintf(cmd.ErrOrStderr(),
  229. "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
  230. continue
  231. }
  232. placed := false
  233. for _, ch := range keyChannels {
  234. if presentSet[ch] {
  235. placed = true
  236. break
  237. }
  238. }
  239. if !placed {
  240. fmt.Fprintf(cmd.ErrOrStderr(),
  241. "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", name, key)
  242. continue
  243. }
  244. for _, ch := range keyChannels {
  245. // A key is applied only where the selection allows it, so
  246. // `--zone logo load <name>` leaves the other channels alone.
  247. if !selectedSet[ch] {
  248. continue
  249. }
  250. targets, _, err := intrgb.ResolveEffect(catalog, settings.Effect, []via.Channel{ch}, targetZone != "")
  251. if err != nil {
  252. fmt.Fprintf(cmd.ErrOrStderr(),
  253. "Warning: effect %q not found on %s, skipping\n", settings.Effect, channelName(ch, target.Display))
  254. continue
  255. }
  256. if err := proto.SetValue(ch, uint8(intrgb.EffectID), targets[0].ID); err != nil {
  257. return err
  258. }
  259. if err := proto.SetValue(ch, uint8(intrgb.Brightness), settings.Brightness); err != nil {
  260. return err
  261. }
  262. if err := proto.SetValue(ch, uint8(intrgb.Speed), settings.Speed); err != nil {
  263. return err
  264. }
  265. if settings.Enabled && settings.Color != "" {
  266. hue, sat, err := hexToHSV(settings.Color)
  267. if err != nil {
  268. fmt.Fprintf(cmd.ErrOrStderr(),
  269. "Warning: invalid color %q on zone %s, skipping\n", settings.Color, channelName(ch, target.Display))
  270. } else if err := proto.SetColor(ch, hue, sat); err != nil {
  271. return err
  272. }
  273. }
  274. applied++
  275. }
  276. }
  277. if applied == 0 {
  278. fmt.Fprintf(cmd.ErrOrStderr(),
  279. "Warning: profile %q has no settings for the selected zone(s); nothing applied\n", name)
  280. }
  281. return nil
  282. },
  283. }
  284. return cmd
  285. }
  286. func NewProfileListCmd() *cobra.Command {
  287. return &cobra.Command{
  288. Use: "list",
  289. Short: "List saved profiles",
  290. Args: cobra.NoArgs,
  291. RunE: func(cmd *cobra.Command, args []string) error {
  292. names, err := ListProfiles()
  293. if err != nil {
  294. return err
  295. }
  296. if names == nil {
  297. names = []string{}
  298. }
  299. type list struct {
  300. Profiles []string `json:"profiles"`
  301. }
  302. return encodeJSON(cmd.OutOrStdout(), list{Profiles: names})
  303. },
  304. }
  305. }
  306. func NewProfileDeleteCmd() *cobra.Command {
  307. var name string
  308. cmd := &cobra.Command{
  309. Use: "delete [name]",
  310. Short: "Delete a saved profile",
  311. Args: cobra.MaximumNArgs(1),
  312. RunE: func(cmd *cobra.Command, args []string) error {
  313. if len(args) == 0 {
  314. name = "default"
  315. } else {
  316. name = args[0]
  317. }
  318. return DeleteProfile(name)
  319. },
  320. }
  321. return cmd
  322. }
  323. func hexToHSV(s string) (uint8, uint8, error) {
  324. if len(s) != 4 {
  325. return 0, 0, fmt.Errorf("invalid HSV hex (expected 4 hex digits): %s", s)
  326. }
  327. var hue, sat uint8
  328. _, err := fmt.Sscanf(s, "%02x%02x", &hue, &sat)
  329. return hue, sat, err
  330. }