profile.go 9.0 KB

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