| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293 |
- package main
- import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- intrgb "netdome.biz/paul/impact-80/internal/rgb"
- "netdome.biz/paul/impact-80/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
- }
|