package main import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "github.com/spf13/cobra" intrgb "netdome.biz/paul/qmk-rgb/internal/rgb" "netdome.biz/paul/qmk-rgb/internal/via" ) // profilesDir is a var so tests can redirect it at a temp directory. var 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 } selected, err := selectedZones() if err != nil { return err } // Iterate the profile in a deterministic order and honour --zone, // so `--zone logo load ` restores the logo and leaves the // other zones untouched. profileZones := make([]intrgb.Zone, 0, len(p.Zones)) for zone := range p.Zones { profileZones = append(profileZones, zone) } sort.Slice(profileZones, func(i, j int) bool { return profileZones[i] < profileZones[j] }) applied := 0 for _, zone := range filterZones(profileZones, selected) { settings := p.Zones[zone] channel := via.Channel(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 } } } } applied++ } if applied == 0 { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: profile %q has no settings for the selected zone(s); nothing applied\n", name) } return nil }, } return cmd } func NewProfileListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List saved profiles", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { names, err := ListProfiles() if err != nil { return err } if names == nil { names = []string{} } type list struct { Profiles []string `json:"profiles"` } return encodeJSON(cmd.OutOrStdout(), list{Profiles: names}) }, } } 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 }