Quellcode durchsuchen

Add rgb profile save/load/list/delete commands

Paul Klumpp vor 2 Wochen
Ursprung
Commit
f4a9ca38a3
2 geänderte Dateien mit 297 neuen und 0 gelöschten Zeilen
  1. 4 0
      cmd/wobkey/main.go
  2. 293 0
      cmd/wobkey/rgb/profile.go

+ 4 - 0
cmd/wobkey/main.go

@@ -112,6 +112,10 @@ func init() {
 		rgb.NewEnableCmd(),
 		rgb.NewDisableCmd(),
 		rgb.NewInfoCmd(),
+		rgb.NewProfileSaveCmd(),
+		rgb.NewProfileLoadCmd(),
+		rgb.NewProfileListCmd(),
+		rgb.NewProfileDeleteCmd(),
 	)
 	rootCmd.AddCommand(rgbCmd)
 	rootCmd.Version = version

+ 293 - 0
cmd/wobkey/rgb/profile.go

@@ -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
+}