profile.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "os"
  6. "path/filepath"
  7. "strings"
  8. intrgb "netdome.biz/paul/impact-80/internal/rgb"
  9. "netdome.biz/paul/impact-80/internal/via"
  10. "github.com/spf13/cobra"
  11. )
  12. const profilesDir = "profiles"
  13. type Profile struct {
  14. Name string `json:"name"`
  15. Version int `json:"version"`
  16. Zones map[intrgb.Zone]*ZoneSettings `json:"zones"`
  17. }
  18. type ZoneSettings struct {
  19. Enabled bool `json:"enabled"`
  20. Effect string `json:"effect"`
  21. Brightness uint8 `json:"brightness"`
  22. Speed uint8 `json:"speed"`
  23. Color string `json:"color"`
  24. }
  25. func ProfilesPath() string {
  26. return filepath.Join(profilesDir)
  27. }
  28. func (p *Profile) Save() error {
  29. if p.Name == "" {
  30. return fmt.Errorf("profile name is required")
  31. }
  32. if err := os.MkdirAll(profilesDir, 0755); err != nil {
  33. return fmt.Errorf("create profiles directory: %w", err)
  34. }
  35. data, err := json.MarshalIndent(p, "", " ")
  36. if err != nil {
  37. return fmt.Errorf("marshal profile: %w", err)
  38. }
  39. path := filepath.Join(profilesDir, sanitizeFilename(p.Name)+".json")
  40. if err := os.WriteFile(path, data, 0644); err != nil {
  41. return fmt.Errorf("write profile: %w", err)
  42. }
  43. return nil
  44. }
  45. func LoadProfile(name string) (*Profile, error) {
  46. path := filepath.Join(profilesDir, sanitizeFilename(name)+".json")
  47. data, err := os.ReadFile(path)
  48. if err != nil {
  49. return nil, fmt.Errorf("read profile %s: %w", name, err)
  50. }
  51. var p Profile
  52. if err := json.Unmarshal(data, &p); err != nil {
  53. return nil, fmt.Errorf("parse profile %s: %w", name, err)
  54. }
  55. return &p, nil
  56. }
  57. func ListProfiles() ([]string, error) {
  58. entries, err := os.ReadDir(profilesDir)
  59. if err != nil {
  60. if os.IsNotExist(err) {
  61. return nil, nil
  62. }
  63. return nil, fmt.Errorf("list profiles: %w", err)
  64. }
  65. var names []string
  66. for _, entry := range entries {
  67. if entry.IsDir() {
  68. continue
  69. }
  70. name := entry.Name()
  71. if strings.HasSuffix(name, ".json") {
  72. name = name[:len(name)-5]
  73. names = append(names, name)
  74. }
  75. }
  76. return names, nil
  77. }
  78. func DeleteProfile(name string) error {
  79. path := filepath.Join(profilesDir, sanitizeFilename(name)+".json")
  80. if err := os.Remove(path); err != nil {
  81. if os.IsNotExist(err) {
  82. return fmt.Errorf("profile %s not found", name)
  83. }
  84. return fmt.Errorf("delete profile %s: %w", name, err)
  85. }
  86. return nil
  87. }
  88. func sanitizeFilename(name string) string {
  89. name = strings.ToLower(name)
  90. name = strings.Map(func(r rune) rune {
  91. if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
  92. return r
  93. }
  94. return '-'
  95. }, name)
  96. if len(name) > 0 && name[0] == '-' {
  97. name = "unnamed-" + name
  98. }
  99. return name
  100. }
  101. // applyProfile reads RGB state from the device and stores it in a Profile.
  102. func applyProfileToProfile(proto rgbProtocol, p *Profile) error {
  103. zones, err := selectedZones()
  104. if err != nil {
  105. // Fall back to all zones for save
  106. zones = intrgb.AllZones()
  107. }
  108. out, err := readInfo(proto, zones)
  109. if err != nil {
  110. return fmt.Errorf("read device state: %w", err)
  111. }
  112. p.Zones = make(map[intrgb.Zone]*ZoneSettings)
  113. for _, zi := range out.Zones {
  114. if zi.Error != "" {
  115. continue
  116. }
  117. p.Zones[zi.Zone] = &ZoneSettings{
  118. Enabled: zi.Enabled,
  119. Effect: zi.Effect,
  120. Brightness: zi.Brightness,
  121. Speed: zi.Speed,
  122. Color: fmt.Sprintf("%02x%02x", zi.Color.Hue, zi.Color.Saturation),
  123. }
  124. }
  125. return nil
  126. }
  127. // loadProfileFromDevice reads RGB state from device and saves it.
  128. func loadProfileFromDevice(name string) error {
  129. proto, err := openRGBProtocol()
  130. if err != nil {
  131. return err
  132. }
  133. defer proto.Close()
  134. p := &Profile{
  135. Name: name,
  136. Version: 1,
  137. }
  138. if err := applyProfileToProfile(proto, p); err != nil {
  139. return err
  140. }
  141. return p.Save()
  142. }
  143. func NewProfileSaveCmd() *cobra.Command {
  144. var name string
  145. cmd := &cobra.Command{
  146. Use: "save [name]",
  147. Short: "Save current RGB state to a profile",
  148. Long: "Read the current RGB settings from the keyboard and save them as a JSON profile in the profiles/ directory.",
  149. Args: cobra.MaximumNArgs(1),
  150. RunE: func(cmd *cobra.Command, args []string) error {
  151. if len(args) == 0 {
  152. name = "default"
  153. } else {
  154. name = args[0]
  155. }
  156. return loadProfileFromDevice(name)
  157. },
  158. }
  159. return cmd
  160. }
  161. func NewProfileLoadCmd() *cobra.Command {
  162. var name string
  163. cmd := &cobra.Command{
  164. Use: "load [name]",
  165. Short: "Load a profile and apply it to the keyboard",
  166. Long: "Read a JSON profile from the profiles/ directory and apply the saved RGB settings to the keyboard.",
  167. Args: cobra.MaximumNArgs(1),
  168. RunE: func(cmd *cobra.Command, args []string) error {
  169. if len(args) == 0 {
  170. name = "default"
  171. } else {
  172. name = args[0]
  173. }
  174. proto, err := openRGBProtocol()
  175. if err != nil {
  176. return err
  177. }
  178. defer proto.Close()
  179. p, err := LoadProfile(name)
  180. if err != nil {
  181. return err
  182. }
  183. for zone, settings := range p.Zones {
  184. channel := via.LEDType(zone.Channel())
  185. // Resolve effect name to ID
  186. targets, _, err := intrgb.ResolveEffect(settings.Effect, []intrgb.Zone{zone})
  187. if err != nil {
  188. fmt.Fprintf(cmd.ErrOrStderr(), "Warning: effect %q not found on zone %s, skipping\n", settings.Effect, zone)
  189. continue
  190. }
  191. if err := proto.SetValue(channel, uint8(intrgb.EffectID), targets[0].ID); err != nil {
  192. return err
  193. }
  194. if err := proto.SetValue(channel, uint8(intrgb.Brightness), settings.Brightness); err != nil {
  195. return err
  196. }
  197. if err := proto.SetValue(channel, uint8(intrgb.Speed), settings.Speed); err != nil {
  198. return err
  199. }
  200. if settings.Enabled {
  201. // Parse hex color (Hue+Sat as 2-byte hex)
  202. if settings.Color != "" {
  203. hue, sat, err := hexToHSV(settings.Color)
  204. if err != nil {
  205. fmt.Fprintf(cmd.ErrOrStderr(), "Warning: invalid color %q on zone %s, skipping\n", settings.Color, zone)
  206. } else {
  207. if err := proto.SetColor(channel, hue, sat); err != nil {
  208. return err
  209. }
  210. }
  211. }
  212. }
  213. }
  214. return nil
  215. },
  216. }
  217. return cmd
  218. }
  219. func NewProfileListCmd() *cobra.Command {
  220. return &cobra.Command{
  221. Use: "list",
  222. Short: "List saved profiles",
  223. RunE: func(cmd *cobra.Command, args []string) error {
  224. names, err := ListProfiles()
  225. if err != nil {
  226. return err
  227. }
  228. if len(names) == 0 {
  229. fmt.Println(`{"profiles":[]}`)
  230. return nil
  231. }
  232. fmt.Println(strings.Join(names, "\n"))
  233. return nil
  234. },
  235. }
  236. }
  237. func NewProfileDeleteCmd() *cobra.Command {
  238. var name string
  239. cmd := &cobra.Command{
  240. Use: "delete [name]",
  241. Short: "Delete a saved profile",
  242. Args: cobra.MaximumNArgs(1),
  243. RunE: func(cmd *cobra.Command, args []string) error {
  244. if len(args) == 0 {
  245. name = "default"
  246. } else {
  247. name = args[0]
  248. }
  249. return DeleteProfile(name)
  250. },
  251. }
  252. return cmd
  253. }
  254. func hexToHSV(s string) (uint8, uint8, error) {
  255. if len(s) != 4 {
  256. return 0, 0, fmt.Errorf("invalid HSV hex (expected 4 hex digits): %s", s)
  257. }
  258. var hue, sat uint8
  259. _, err := fmt.Sscanf(s, "%02x%02x", &hue, &sat)
  260. return hue, sat, err
  261. }