package main import ( "encoding/json" "fmt" "io" "os" "path/filepath" "sort" "strings" "github.com/spf13/cobra" intrgb "netdome.biz/paul/qmk-rgb/internal/rgb" "netdome.biz/paul/qmk-rgb/internal/via" ) type Profile struct { Name string `json:"name"` Version int `json:"version"` Board *Board `json:"board,omitempty"` Zones map[string]*ZoneSettings `json:"zones"` } // Board identifies the keyboard a profile was saved from. A profile stores // effect names, and names belong to a board, so without this a profile written // for one keyboard would be applied to another without a word. The pair is // spelled as hex strings, the way the VIA definition files // spell it, so the three can be read side by side. type Board struct { VendorID string `json:"vendorId"` ProductID string `json:"productId"` } // boardMatch is what comparing a profile's board to the connected one tells us. type boardMatch int const ( // boardUnknown is a profile written before profiles carried a board. boardUnknown boardMatch = iota boardSame boardOther ) func boardFor(vendorID, productID uint16) *Board { return &Board{ VendorID: fmt.Sprintf("0x%04X", vendorID), ProductID: fmt.Sprintf("0x%04X", productID), } } // MatchesBoard reports whether a profile was saved from the connected keyboard, // was saved from another one, or says nothing about it. func (p *Profile) MatchesBoard(vendorID, productID uint16) boardMatch { if p == nil || p.Board == nil { return boardUnknown } if strings.EqualFold(p.Board.VendorID, fmt.Sprintf("0x%04X", vendorID)) && strings.EqualFold(p.Board.ProductID, fmt.Sprintf("0x%04X", productID)) { return boardSame } return boardOther } // boardMismatchWarning says which profile belongs to which keyboard, because // the user has to be able to tell which of their profiles is the wrong one. func (p *Profile) boardMismatchWarning(vendorID, productID uint16) string { return fmt.Sprintf("Warning: profile %q was saved for keyboard %s/%s, and this keyboard is %s/%s; "+ "its effect names may not exist here\n", p.Name, p.Board.VendorID, p.Board.ProductID, fmt.Sprintf("0x%04X", vendorID), fmt.Sprintf("0x%04X", productID)) } // listLine is one profile as the list command prints it. func (p *Profile) listLine() string { if p.Board == nil { return p.Name } return fmt.Sprintf("%s %s/%s", p.Name, p.Board.VendorID, p.Board.ProductID) } type ZoneSettings struct { Enabled bool `json:"enabled"` Effect string `json:"effect"` Brightness uint8 `json:"brightness"` Speed uint8 `json:"speed"` Color string `json:"color"` } // ProfilesPath is the directory the profiles live in. func ProfilesPath() string { return profilesPath() } // profileFileName is the file a name maps to, without a directory. func profileFileName(name string) string { return sanitizeFilename(name) + ".json" } // resolveProfileTarget maps one profile argument to the file it names, and to the // name a profile written there should carry. // // An argument ending in .json is a path and is used exactly as given, so // `load profiles/lava.json` reads that file and `save ./lava.json` writes it. It // is not a search: the argument names the file, so there is nothing to search // for, and the same argument names the same file from any working directory. // The suffix is matched without regard to case so the rule is the same on Linux, // macOS and Windows, and the file system rather than this code decides whether the // case is right. No separator is looked for — the Windows file API takes both `/` // and `\`, so a check for either would be a platform difference with no behaviour // behind it — and nothing here joins the path to a directory, so a path the // operating system rejects fails as itself rather than as a name. // // Every other argument is a profile name, and a name lives in the per-user // directory: profilesPath and the sanitized file name. A name is the only way in // there, and an argument that names a file is never also a name — which is what // keeps a .json argument from also resolving to `lala-json.json`. func resolveProfileTarget(arg string) (path string, name string, isPath bool) { if !strings.HasSuffix(strings.ToLower(arg), ".json") { return profileFilePath(arg), arg, false } base := filepath.Base(arg) return arg, strings.TrimSuffix(base, filepath.Ext(base)), true } // profileFilePath is the file a name maps to, in the per-user directory. It is the // name form of resolveProfileTarget on its own, for the commands that take no path // at all: `delete` and `list` are name-only, so `delete lava.json` names the // profile `lava-json` and not a file, and the one that says which file it removed // must not be the one that could remove a file outside the per-user directory. func profileFilePath(name string) string { return filepath.Join(profilesPath(), profileFileName(name)) } // Save writes the profile into the per-user directory under its own name, which is // what every caller that has a name and no path means. It creates that directory, // which is the one directory a save may create: it is this tool's own, and a user // who has never saved a profile does not have it, so the first `save lava` is // exactly the case that needs it. func (p *Profile) Save() error { if _, err := ensureDataDir(profilesPath()); err != nil { return fmt.Errorf("create profiles directory: %w", err) } return p.saveTo(profileFilePath(p.Name)) } // saveTo writes the profile to one file, in a directory that has to be there // already. A caller that names a path names a directory the tool did not create, // and building it turns `save profiles/neu/x.json` with a typo in it into a tree of // empty ones that nothing lists and nothing cleans up — reported, meanwhile, as a // save. It resolves nothing either: the caller decides where the file goes, so // that the rule that decides is the only one there is. func (p *Profile) saveTo(path string) error { if p.Name == "" { return fmt.Errorf("profile name is required") } dir := filepath.Dir(path) if _, err := os.Stat(dir); err != nil { if os.IsNotExist(err) { return fmt.Errorf("profile directory %s does not exist; create it first, or save a name", dir) } return fmt.Errorf("profile directory %s: %w", dir, err) } data, err := json.MarshalIndent(p, "", " ") if err != nil { return fmt.Errorf("marshal profile: %w", err) } 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, _, isPath := resolveProfileTarget(name) // A path is stat'd before it is read, so a directory says it is one. Reading a // directory fails anyway, and reporting that as "not found" would be the one // answer a user cannot act on. if isPath { info, err := os.Stat(path) switch { case os.IsNotExist(err): return nil, fmt.Errorf("profile file %s not found", path) case err != nil: return nil, fmt.Errorf("read profile file %s: %w", path, err) case info.IsDir(): return nil, fmt.Errorf("profile file %s is a directory", path) } } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("profile %s not found in %s", name, profilesPath()) } 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(profilesPath()) 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") { continue } names = append(names, strings.TrimSuffix(name, ".json")) } return names, nil } // DeleteProfile removes the profile of that name from the per-user directory and // returns the file it removed, so a command that reports the deletion says which // file it was. A name only: nothing here resolves a path, so an argument ending in // .json names the profile `lava-json`. func DeleteProfile(name string) (string, error) { path := profileFilePath(name) if err := os.Remove(path); err != nil { if os.IsNotExist(err) { return "", fmt.Errorf("profile %s not found in %s", name, profilesPath()) } return "", fmt.Errorf("delete profile %s: %w", name, err) } return path, 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, channels []via.Channel, display map[uint16]string, catalog *intrgb.Catalog, p *Profile) error { out, err := readInfo(proto, channels, display, catalog) if err != nil { return fmt.Errorf("read device state: %w", err) } p.Zones = make(map[string]*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. Every channel // is recorded, so the profile a `load` applies later is the whole keyboard and // not the part of it that happened to be named. Warnings go to warn, which is the // command's stderr. Where the file goes is the caller's decision: isPath is what // resolveProfileTarget said about the argument, and a name is the only form that // brings a directory with it. func loadProfileFromDevice(path, name string, isPath bool, warn io.Writer) error { proto, target, channels, err := openTarget("") if err != nil { return err } defer proto.Close() catalog, _, err := resolveCatalog(target) if err != nil { return err } if catalog == nil { // The profile stores effect names and a board without a catalog has none // to store, so every channel is written as "unknown" and cannot be // restored. Say so here, where the user can still act on it. The way out // names the zone, because the command takes it as its first argument and // an index on its own would be read as a channel name. fmt.Fprintf(warn, "Warning: this keyboard has no effect names, so the profile records effect %q and cannot restore it; "+ "run `keyboard fetch` for its VIA definition, or set an effect with `effect `\n", "unknown") } p := &Profile{ Name: name, Version: 1, Board: boardFor(target.Device.VendorID, target.Device.ProductID), } if err := applyProfileToProfile(proto, channels, target.Display, catalog, p); err != nil { return err } if isPath { return p.saveTo(path) } return p.Save() } // savedProfileLine is what a save says it did. The path is annotated the way every // other message here is, and the name is in the line because the file is named // after it: a save that only reported success would be silent about where a file // it was told to write by path actually went, which is the one thing about a save // worth reporting. func savedProfileLine(name, path string) string { return fmt.Sprintf("Saved profile %s to %s\n", name, describeDataDir(path)) } // deletedProfileLine is what a delete says it did, in the same words as the save: // the name and the file it removed, and the file annotated as the user's when that // is where it was. A delete that printed nothing is a command that has run and // cannot be told apart from one that removed something else. func deletedProfileLine(name, path string) string { return fmt.Sprintf("Deleted profile %s from %s\n", name, describeDataDir(path)) } func NewProfileSaveCmd() *cobra.Command { return &cobra.Command{ Use: "save [name|file]", Short: "Save current RGB state to a profile", Long: "Read the current RGB settings from every channel of the keyboard and save\n" + "them as a JSON profile. A name is written as .json in the per-user\n" + "profiles/ directory, which is created if it is not there yet; an argument\n" + "ending in .json is a path, and that file is written where it says, in a\n" + "directory that has to exist already. It reports the file it wrote.\n" + "\n" + "A profile is always complete. Recording only some of the channels would let a\n" + "later `load` apply them and leave the rest as they were, which reads as a\n" + "zone the profile had nothing to say about.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { arg := "default" if len(args) > 0 { arg = args[0] } path, name, isPath := resolveProfileTarget(arg) if err := loadProfileFromDevice(path, name, isPath, cmd.ErrOrStderr()); err != nil { return err } fmt.Fprint(cmd.OutOrStdout(), savedProfileLine(name, path)) return nil }, } } func NewProfileLoadCmd() *cobra.Command { // Not withZoneArgs: the profile name comes first and the zone second, so // the zone completion only applies once the name has been typed. cmd := &cobra.Command{ ValidArgsFunction: completeLoadArgs, Use: "load [zone]", Short: "Load a profile and apply it to the keyboard", Long: "Read a JSON profile and apply the saved RGB settings to the keyboard. A\n" + "name is read from the per-user profiles/ directory; an argument ending in\n" + ".json is a path, and that file is read where it says. Without a zone the\n" + "profile is applied to every channel it names.", RunE: func(cmd *cobra.Command, args []string) error { arg := args[0] zone := "" if len(args) == 2 { zone = args[1] } proto, target, selected, err := openTarget(zone) if err != nil { return err } defer proto.Close() p, err := LoadProfile(arg) if err != nil { return err } // A profile belongs to the keyboard it was saved from: its effect // names are that board's. Applying it elsewhere is allowed, because // the names that do not exist are reported per key below, but it is // said out loud, since a mismatch is the likeliest reason. if p.MatchesBoard(target.Device.VendorID, target.Device.ProductID) == boardOther { fmt.Fprintln(cmd.ErrOrStderr(), p.boardMismatchWarning(target.Device.VendorID, target.Device.ProductID)) } catalog, _, err := resolveCatalog(target) if err != nil { return err } keys := make([]string, 0, len(p.Zones)) for key := range p.Zones { keys = append(keys, key) } sort.Strings(keys) // A key is a channel name as the file wrote it. Resolving it here // means a profile written before a board was renamed reports the // key it cannot place instead of silently applying nothing. selectedSet := make(map[via.Channel]bool, len(selected)) for _, ch := range selected { selectedSet[ch] = true } // Presence and selection are two different questions. A key the // selection leaves out is the user's own choice and stays quiet; a // key naming a channel the keyboard does not have has nowhere to go // and is reported, whatever the selection says. present, err := proto.DetectChannels() if err != nil { return err } presentSet := make(map[via.Channel]bool, len(present)) for _, ch := range present { presentSet[ch] = true } // Without a catalog there are no effect names to look the // profile's value up in, so the whole load is impossible. Say that // once instead of reporting every key's name as not found. if catalog == nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: profile %q has no effect names for this keyboard, so nothing applied; "+ "run `keyboard fetch` for its VIA definition\n", arg) return nil } applied := 0 for _, key := range keys { settings := p.Zones[key] keyChannels, err := resolveZoneName(key, target.Display, target.Alternatives) if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", arg, key) continue } placed := false for _, ch := range keyChannels { if presentSet[ch] { placed = true break } } if !placed { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", arg, key) continue } for _, ch := range keyChannels { // A key is applied only where the selection allows it, so // `load logo` leaves the other channels alone. if !selectedSet[ch] { continue } // A zone the caller named is an explicit request for it, so an // effect the channel does not have is said rather than skipped; // without one the whole profile is being applied and a key the // board cannot do is worth a warning instead of a refusal. targets, _, err := intrgb.ResolveEffect(catalog, settings.Effect, []via.Channel{ch}, zone != "") if err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: effect %q not found on %s, skipping\n", settings.Effect, channelName(ch, target.Display)) continue } // ResolveEffect can answer with nothing to do and no // error: the name exists on the board but not on this // channel, and no zone was named, so it is skipped. Indexing // its result would crash here instead of saying so. if len(targets) == 0 { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: effect %q not found on %s, skipping\n", settings.Effect, channelName(ch, target.Display)) continue } if err := proto.SetValue(ch, uint8(intrgb.EffectID), targets[0].ID); err != nil { return err } if err := proto.SetValue(ch, uint8(intrgb.Brightness), settings.Brightness); err != nil { return err } if err := proto.SetValue(ch, uint8(intrgb.Speed), settings.Speed); err != nil { return err } if settings.Enabled && 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, channelName(ch, target.Display)) } else if err := proto.SetColor(ch, 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", arg) } return nil }, } cmd.Args = zoneArgs(1, 2, "a profile name or file and at most a zone") 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{} } lines := make([]string, 0, len(names)) for _, name := range names { p, err := LoadProfile(name) if err != nil { // A file that cannot be read is still a name in the // directory; the name is what the user can act on. lines = append(lines, name) continue } lines = append(lines, p.listLine()) } if jsonOutput { return encodeJSON(cmd.OutOrStdout(), struct { Profiles []string `json:"profiles"` }{Profiles: names}) } for _, line := range lines { fmt.Fprintln(cmd.OutOrStdout(), line) } return nil }, } } func NewProfileDeleteCmd() *cobra.Command { cmd := &cobra.Command{ ValidArgsFunction: completeProfileNames, Use: "delete [name]", Short: "Delete a saved profile", Long: "Delete a profile from the per-user profiles/ directory, by name; without a\n" + "name it deletes `default`. A path is not accepted, so an argument ending in\n" + ".json names the profile without the dots rather than a file to remove. It\n" + "reports the file it removed.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { name := "default" if len(args) > 0 { name = args[0] } path, err := DeleteProfile(name) if err != nil { return err } fmt.Fprint(cmd.OutOrStdout(), deletedProfileLine(name, path)) return nil }, } 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 }