|
|
@@ -0,0 +1,293 @@
|
|
|
+package rgb
|
|
|
+
|
|
|
+import (
|
|
|
+ "encoding/json"
|
|
|
+ "fmt"
|
|
|
+ "os"
|
|
|
+ "path/filepath"
|
|
|
+ "strings"
|
|
|
+
|
|
|
+ intrgb "github.com/wobkey/rgb/internal/rgb"
|
|
|
+ "github.com/wobkey/rgb/internal/via"
|
|
|
+ "github.com/spf13/cobra"
|
|
|
+)
|
|
|
+
|
|
|
+const profilesDir = "profiles"
|
|
|
+
|
|
|
+type Profile struct {
|
|
|
+ Name string `json:"name"`
|
|
|
+ Version int `json:"version"`
|
|
|
+ Zones map[intrgb.Zone]*ZoneSettings `json:"zones"`
|
|
|
+}
|
|
|
+
|
|
|
+type ZoneSettings struct {
|
|
|
+ Enabled bool `json:"enabled"`
|
|
|
+ Effect string `json:"effect"`
|
|
|
+ Brightness uint8 `json:"brightness"`
|
|
|
+ Speed uint8 `json:"speed"`
|
|
|
+ Color string `json:"color"`
|
|
|
+}
|
|
|
+
|
|
|
+func ProfilesPath() string {
|
|
|
+ return filepath.Join(profilesDir)
|
|
|
+}
|
|
|
+
|
|
|
+func (p *Profile) Save() error {
|
|
|
+ if p.Name == "" {
|
|
|
+ return fmt.Errorf("profile name is required")
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := os.MkdirAll(profilesDir, 0755); err != nil {
|
|
|
+ return fmt.Errorf("create profiles directory: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ data, err := json.MarshalIndent(p, "", " ")
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("marshal profile: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ path := filepath.Join(profilesDir, sanitizeFilename(p.Name)+".json")
|
|
|
+ if err := os.WriteFile(path, data, 0644); err != nil {
|
|
|
+ return fmt.Errorf("write profile: %w", err)
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func LoadProfile(name string) (*Profile, error) {
|
|
|
+ path := filepath.Join(profilesDir, sanitizeFilename(name)+".json")
|
|
|
+ data, err := os.ReadFile(path)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("read profile %s: %w", name, err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var p Profile
|
|
|
+ if err := json.Unmarshal(data, &p); err != nil {
|
|
|
+ return nil, fmt.Errorf("parse profile %s: %w", name, err)
|
|
|
+ }
|
|
|
+ return &p, nil
|
|
|
+}
|
|
|
+
|
|
|
+func ListProfiles() ([]string, error) {
|
|
|
+ entries, err := os.ReadDir(profilesDir)
|
|
|
+ if err != nil {
|
|
|
+ if os.IsNotExist(err) {
|
|
|
+ return nil, nil
|
|
|
+ }
|
|
|
+ return nil, fmt.Errorf("list profiles: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var names []string
|
|
|
+ for _, entry := range entries {
|
|
|
+ if entry.IsDir() {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ name := entry.Name()
|
|
|
+ if strings.HasSuffix(name, ".json") {
|
|
|
+ name = name[:len(name)-5]
|
|
|
+ names = append(names, name)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return names, nil
|
|
|
+}
|
|
|
+
|
|
|
+func DeleteProfile(name string) error {
|
|
|
+ path := filepath.Join(profilesDir, sanitizeFilename(name)+".json")
|
|
|
+ if err := os.Remove(path); err != nil {
|
|
|
+ if os.IsNotExist(err) {
|
|
|
+ return fmt.Errorf("profile %s not found", name)
|
|
|
+ }
|
|
|
+ return fmt.Errorf("delete profile %s: %w", name, err)
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func sanitizeFilename(name string) string {
|
|
|
+ name = strings.ToLower(name)
|
|
|
+ name = strings.Map(func(r rune) rune {
|
|
|
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
|
|
+ return r
|
|
|
+ }
|
|
|
+ return '-'
|
|
|
+ }, name)
|
|
|
+ if len(name) > 0 && name[0] == '-' {
|
|
|
+ name = "unnamed-" + name
|
|
|
+ }
|
|
|
+ return name
|
|
|
+}
|
|
|
+
|
|
|
+// applyProfile reads RGB state from the device and stores it in a Profile.
|
|
|
+func applyProfileToProfile(proto rgbProtocol, p *Profile) error {
|
|
|
+ zones, err := selectedZones()
|
|
|
+ if err != nil {
|
|
|
+ // Fall back to all zones for save
|
|
|
+ zones = intrgb.AllZones()
|
|
|
+ }
|
|
|
+
|
|
|
+ out, err := readInfo(proto, zones)
|
|
|
+ if err != nil {
|
|
|
+ return fmt.Errorf("read device state: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ p.Zones = make(map[intrgb.Zone]*ZoneSettings)
|
|
|
+ for _, zi := range out.Zones {
|
|
|
+ if zi.Error != "" {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ p.Zones[zi.Zone] = &ZoneSettings{
|
|
|
+ Enabled: zi.Enabled,
|
|
|
+ Effect: zi.Effect,
|
|
|
+ Brightness: zi.Brightness,
|
|
|
+ Speed: zi.Speed,
|
|
|
+ Color: fmt.Sprintf("%02x%02x", zi.Color.Hue, zi.Color.Saturation),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// loadProfileFromDevice reads RGB state from device and saves it.
|
|
|
+func loadProfileFromDevice(name string) error {
|
|
|
+ proto, err := openRGBProtocol()
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ defer proto.Close()
|
|
|
+
|
|
|
+ p := &Profile{
|
|
|
+ Name: name,
|
|
|
+ Version: 1,
|
|
|
+ }
|
|
|
+ if err := applyProfileToProfile(proto, p); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ return p.Save()
|
|
|
+}
|
|
|
+
|
|
|
+func NewProfileSaveCmd() *cobra.Command {
|
|
|
+ var name string
|
|
|
+ cmd := &cobra.Command{
|
|
|
+ Use: "save [name]",
|
|
|
+ Short: "Save current RGB state to a profile",
|
|
|
+ Long: "Read the current RGB settings from the keyboard and save them as a JSON profile in the profiles/ directory.",
|
|
|
+ Args: cobra.MaximumNArgs(1),
|
|
|
+ RunE: func(cmd *cobra.Command, args []string) error {
|
|
|
+ if len(args) == 0 {
|
|
|
+ name = "default"
|
|
|
+ } else {
|
|
|
+ name = args[0]
|
|
|
+ }
|
|
|
+ return loadProfileFromDevice(name)
|
|
|
+ },
|
|
|
+ }
|
|
|
+ return cmd
|
|
|
+}
|
|
|
+
|
|
|
+func NewProfileLoadCmd() *cobra.Command {
|
|
|
+ var name string
|
|
|
+ cmd := &cobra.Command{
|
|
|
+ Use: "load [name]",
|
|
|
+ Short: "Load a profile and apply it to the keyboard",
|
|
|
+ Long: "Read a JSON profile from the profiles/ directory and apply the saved RGB settings to the keyboard.",
|
|
|
+ Args: cobra.MaximumNArgs(1),
|
|
|
+ RunE: func(cmd *cobra.Command, args []string) error {
|
|
|
+ if len(args) == 0 {
|
|
|
+ name = "default"
|
|
|
+ } else {
|
|
|
+ name = args[0]
|
|
|
+ }
|
|
|
+
|
|
|
+ proto, err := openRGBProtocol()
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ defer proto.Close()
|
|
|
+
|
|
|
+ p, err := LoadProfile(name)
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ for zone, settings := range p.Zones {
|
|
|
+ channel := via.LEDType(zone.Channel())
|
|
|
+
|
|
|
+ // Resolve effect name to ID
|
|
|
+ targets, _, err := intrgb.ResolveEffect(settings.Effect, []intrgb.Zone{zone})
|
|
|
+ if err != nil {
|
|
|
+ fmt.Fprintf(cmd.ErrOrStderr(), "Warning: effect %q not found on zone %s, skipping\n", settings.Effect, zone)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ if err := proto.SetValue(channel, uint8(intrgb.EffectID), targets[0].ID); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if err := proto.SetValue(channel, uint8(intrgb.Brightness), settings.Brightness); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if err := proto.SetValue(channel, uint8(intrgb.Speed), settings.Speed); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+
|
|
|
+ if settings.Enabled {
|
|
|
+ // Parse hex color (Hue+Sat as 2-byte hex)
|
|
|
+ if settings.Color != "" {
|
|
|
+ hue, sat, err := hexToHSV(settings.Color)
|
|
|
+ if err != nil {
|
|
|
+ fmt.Fprintf(cmd.ErrOrStderr(), "Warning: invalid color %q on zone %s, skipping\n", settings.Color, zone)
|
|
|
+ } else {
|
|
|
+ if err := proto.SetColor(channel, hue, sat); err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return nil
|
|
|
+ },
|
|
|
+ }
|
|
|
+ return cmd
|
|
|
+}
|
|
|
+
|
|
|
+func NewProfileListCmd() *cobra.Command {
|
|
|
+ return &cobra.Command{
|
|
|
+ Use: "list",
|
|
|
+ Short: "List saved profiles",
|
|
|
+ RunE: func(cmd *cobra.Command, args []string) error {
|
|
|
+ names, err := ListProfiles()
|
|
|
+ if err != nil {
|
|
|
+ return err
|
|
|
+ }
|
|
|
+ if len(names) == 0 {
|
|
|
+ fmt.Println(`{"profiles":[]}`)
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+ fmt.Println(strings.Join(names, "\n"))
|
|
|
+ return nil
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func NewProfileDeleteCmd() *cobra.Command {
|
|
|
+ var name string
|
|
|
+ cmd := &cobra.Command{
|
|
|
+ Use: "delete [name]",
|
|
|
+ Short: "Delete a saved profile",
|
|
|
+ Args: cobra.MaximumNArgs(1),
|
|
|
+ RunE: func(cmd *cobra.Command, args []string) error {
|
|
|
+ if len(args) == 0 {
|
|
|
+ name = "default"
|
|
|
+ } else {
|
|
|
+ name = args[0]
|
|
|
+ }
|
|
|
+ return DeleteProfile(name)
|
|
|
+ },
|
|
|
+ }
|
|
|
+ return cmd
|
|
|
+}
|
|
|
+
|
|
|
+func hexToHSV(s string) (uint8, uint8, error) {
|
|
|
+ if len(s) != 4 {
|
|
|
+ return 0, 0, fmt.Errorf("invalid HSV hex (expected 4 hex digits): %s", s)
|
|
|
+ }
|
|
|
+ var hue, sat uint8
|
|
|
+ _, err := fmt.Sscanf(s, "%02x%02x", &hue, &sat)
|
|
|
+ return hue, sat, err
|
|
|
+}
|