profile.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. package main
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "io"
  6. "os"
  7. "path/filepath"
  8. "sort"
  9. "strings"
  10. "github.com/spf13/cobra"
  11. intrgb "netdome.biz/paul/qmk-rgb/internal/rgb"
  12. "netdome.biz/paul/qmk-rgb/internal/via"
  13. )
  14. type Profile struct {
  15. Name string `json:"name"`
  16. Version int `json:"version"`
  17. Board *Board `json:"board,omitempty"`
  18. Zones map[string]*ZoneSettings `json:"zones"`
  19. }
  20. // Board identifies the keyboard a profile was saved from. A profile stores
  21. // effect names, and names belong to a board, so without this a profile written
  22. // for one keyboard would be applied to another without a word. The pair is
  23. // spelled as hex strings, the way the VIA definition files
  24. // spell it, so the three can be read side by side.
  25. type Board struct {
  26. VendorID string `json:"vendorId"`
  27. ProductID string `json:"productId"`
  28. }
  29. // boardMatch is what comparing a profile's board to the connected one tells us.
  30. type boardMatch int
  31. const (
  32. // boardUnknown is a profile written before profiles carried a board.
  33. boardUnknown boardMatch = iota
  34. boardSame
  35. boardOther
  36. )
  37. func boardFor(vendorID, productID uint16) *Board {
  38. return &Board{
  39. VendorID: fmt.Sprintf("0x%04X", vendorID),
  40. ProductID: fmt.Sprintf("0x%04X", productID),
  41. }
  42. }
  43. // MatchesBoard reports whether a profile was saved from the connected keyboard,
  44. // was saved from another one, or says nothing about it.
  45. func (p *Profile) MatchesBoard(vendorID, productID uint16) boardMatch {
  46. if p == nil || p.Board == nil {
  47. return boardUnknown
  48. }
  49. if strings.EqualFold(p.Board.VendorID, fmt.Sprintf("0x%04X", vendorID)) &&
  50. strings.EqualFold(p.Board.ProductID, fmt.Sprintf("0x%04X", productID)) {
  51. return boardSame
  52. }
  53. return boardOther
  54. }
  55. // boardMismatchWarning says which profile belongs to which keyboard, because
  56. // the user has to be able to tell which of their profiles is the wrong one.
  57. func (p *Profile) boardMismatchWarning(vendorID, productID uint16) string {
  58. return fmt.Sprintf("Warning: profile %q was saved for keyboard %s/%s, and this keyboard is %s/%s; "+
  59. "its effect names may not exist here\n",
  60. p.Name, p.Board.VendorID, p.Board.ProductID,
  61. fmt.Sprintf("0x%04X", vendorID), fmt.Sprintf("0x%04X", productID))
  62. }
  63. // listLine is one profile as the list command prints it.
  64. func (p *Profile) listLine() string {
  65. if p.Board == nil {
  66. return p.Name
  67. }
  68. return fmt.Sprintf("%s %s/%s", p.Name, p.Board.VendorID, p.Board.ProductID)
  69. }
  70. type ZoneSettings struct {
  71. Enabled bool `json:"enabled"`
  72. Effect string `json:"effect"`
  73. Brightness uint8 `json:"brightness"`
  74. Speed uint8 `json:"speed"`
  75. Color string `json:"color"`
  76. }
  77. // ProfilesPath is the directory the profiles live in.
  78. func ProfilesPath() string {
  79. return profilesPath()
  80. }
  81. // profileFileName is the file a name maps to, without a directory.
  82. func profileFileName(name string) string {
  83. return sanitizeFilename(name) + ".json"
  84. }
  85. // resolveProfileTarget maps one profile argument to the file it names, and to the
  86. // name a profile written there should carry.
  87. //
  88. // An argument ending in .json is a path and is used exactly as given, so
  89. // `load profiles/lava.json` reads that file and `save ./lava.json` writes it. It
  90. // is not a search: the argument names the file, so there is nothing to search
  91. // for, and the same argument names the same file from any working directory.
  92. // The suffix is matched without regard to case so the rule is the same on Linux,
  93. // macOS and Windows, and the file system rather than this code decides whether the
  94. // case is right. No separator is looked for — the Windows file API takes both `/`
  95. // and `\`, so a check for either would be a platform difference with no behaviour
  96. // behind it — and nothing here joins the path to a directory, so a path the
  97. // operating system rejects fails as itself rather than as a name.
  98. //
  99. // Every other argument is a profile name, and a name lives in the per-user
  100. // directory: profilesPath and the sanitized file name. A name is the only way in
  101. // there, and an argument that names a file is never also a name — which is what
  102. // keeps a .json argument from also resolving to `lala-json.json`.
  103. func resolveProfileTarget(arg string) (path string, name string, isPath bool) {
  104. if !strings.HasSuffix(strings.ToLower(arg), ".json") {
  105. return profileFilePath(arg), arg, false
  106. }
  107. base := filepath.Base(arg)
  108. return arg, strings.TrimSuffix(base, filepath.Ext(base)), true
  109. }
  110. // profileFilePath is the file a name maps to, in the per-user directory. It is the
  111. // name form of resolveProfileTarget on its own, for the commands that take no path
  112. // at all: `delete` and `list` are name-only, so `delete lava.json` names the
  113. // profile `lava-json` and not a file, and the one that says which file it removed
  114. // must not be the one that could remove a file outside the per-user directory.
  115. func profileFilePath(name string) string {
  116. return filepath.Join(profilesPath(), profileFileName(name))
  117. }
  118. // Save writes the profile into the per-user directory under its own name, which is
  119. // what every caller that has a name and no path means. It creates that directory,
  120. // which is the one directory a save may create: it is this tool's own, and a user
  121. // who has never saved a profile does not have it, so the first `save lava` is
  122. // exactly the case that needs it.
  123. func (p *Profile) Save() error {
  124. if _, err := ensureDataDir(profilesPath()); err != nil {
  125. return fmt.Errorf("create profiles directory: %w", err)
  126. }
  127. return p.saveTo(profileFilePath(p.Name))
  128. }
  129. // saveTo writes the profile to one file, in a directory that has to be there
  130. // already. A caller that names a path names a directory the tool did not create,
  131. // and building it turns `save profiles/neu/x.json` with a typo in it into a tree of
  132. // empty ones that nothing lists and nothing cleans up — reported, meanwhile, as a
  133. // save. It resolves nothing either: the caller decides where the file goes, so
  134. // that the rule that decides is the only one there is.
  135. func (p *Profile) saveTo(path string) error {
  136. if p.Name == "" {
  137. return fmt.Errorf("profile name is required")
  138. }
  139. dir := filepath.Dir(path)
  140. if _, err := os.Stat(dir); err != nil {
  141. if os.IsNotExist(err) {
  142. return fmt.Errorf("profile directory %s does not exist; create it first, or save a name", dir)
  143. }
  144. return fmt.Errorf("profile directory %s: %w", dir, err)
  145. }
  146. data, err := json.MarshalIndent(p, "", " ")
  147. if err != nil {
  148. return fmt.Errorf("marshal profile: %w", err)
  149. }
  150. if err := os.WriteFile(path, data, 0644); err != nil {
  151. return fmt.Errorf("write profile: %w", err)
  152. }
  153. return nil
  154. }
  155. func LoadProfile(name string) (*Profile, error) {
  156. path, _, isPath := resolveProfileTarget(name)
  157. // A path is stat'd before it is read, so a directory says it is one. Reading a
  158. // directory fails anyway, and reporting that as "not found" would be the one
  159. // answer a user cannot act on.
  160. if isPath {
  161. info, err := os.Stat(path)
  162. switch {
  163. case os.IsNotExist(err):
  164. return nil, fmt.Errorf("profile file %s not found", path)
  165. case err != nil:
  166. return nil, fmt.Errorf("read profile file %s: %w", path, err)
  167. case info.IsDir():
  168. return nil, fmt.Errorf("profile file %s is a directory", path)
  169. }
  170. }
  171. data, err := os.ReadFile(path)
  172. if err != nil {
  173. if os.IsNotExist(err) {
  174. return nil, fmt.Errorf("profile %s not found in %s", name, profilesPath())
  175. }
  176. return nil, fmt.Errorf("read profile %s: %w", name, err)
  177. }
  178. var p Profile
  179. if err := json.Unmarshal(data, &p); err != nil {
  180. return nil, fmt.Errorf("parse profile %s: %w", name, err)
  181. }
  182. return &p, nil
  183. }
  184. func ListProfiles() ([]string, error) {
  185. entries, err := os.ReadDir(profilesPath())
  186. if err != nil {
  187. if os.IsNotExist(err) {
  188. return nil, nil
  189. }
  190. return nil, fmt.Errorf("list profiles: %w", err)
  191. }
  192. var names []string
  193. for _, entry := range entries {
  194. if entry.IsDir() {
  195. continue
  196. }
  197. name := entry.Name()
  198. if !strings.HasSuffix(name, ".json") {
  199. continue
  200. }
  201. names = append(names, strings.TrimSuffix(name, ".json"))
  202. }
  203. return names, nil
  204. }
  205. // DeleteProfile removes the profile of that name from the per-user directory and
  206. // returns the file it removed, so a command that reports the deletion says which
  207. // file it was. A name only: nothing here resolves a path, so an argument ending in
  208. // .json names the profile `lava-json`.
  209. func DeleteProfile(name string) (string, error) {
  210. path := profileFilePath(name)
  211. if err := os.Remove(path); err != nil {
  212. if os.IsNotExist(err) {
  213. return "", fmt.Errorf("profile %s not found in %s", name, profilesPath())
  214. }
  215. return "", fmt.Errorf("delete profile %s: %w", name, err)
  216. }
  217. return path, nil
  218. }
  219. func sanitizeFilename(name string) string {
  220. name = strings.ToLower(name)
  221. name = strings.Map(func(r rune) rune {
  222. if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
  223. return r
  224. }
  225. return '-'
  226. }, name)
  227. if len(name) > 0 && name[0] == '-' {
  228. name = "unnamed-" + name
  229. }
  230. return name
  231. }
  232. // applyProfile reads RGB state from the device and stores it in a Profile.
  233. func applyProfileToProfile(proto rgbProtocol, channels []via.Channel, display map[uint16]string, catalog *intrgb.Catalog, p *Profile) error {
  234. out, err := readInfo(proto, channels, display, catalog)
  235. if err != nil {
  236. return fmt.Errorf("read device state: %w", err)
  237. }
  238. p.Zones = make(map[string]*ZoneSettings)
  239. for _, zi := range out.Zones {
  240. if zi.Error != "" {
  241. continue
  242. }
  243. p.Zones[zi.Zone] = &ZoneSettings{
  244. Enabled: zi.Enabled,
  245. Effect: zi.Effect,
  246. Brightness: zi.Brightness,
  247. Speed: zi.Speed,
  248. Color: fmt.Sprintf("%02x%02x", zi.Color.Hue, zi.Color.Saturation),
  249. }
  250. }
  251. return nil
  252. }
  253. // loadProfileFromDevice reads RGB state from device and saves it. Every channel
  254. // is recorded, so the profile a `load` applies later is the whole keyboard and
  255. // not the part of it that happened to be named. Warnings go to warn, which is the
  256. // command's stderr. Where the file goes is the caller's decision: isPath is what
  257. // resolveProfileTarget said about the argument, and a name is the only form that
  258. // brings a directory with it.
  259. func loadProfileFromDevice(path, name string, isPath bool, warn io.Writer) error {
  260. proto, target, channels, err := openTarget("")
  261. if err != nil {
  262. return err
  263. }
  264. defer proto.Close()
  265. catalog, _, err := resolveCatalog(target)
  266. if err != nil {
  267. return err
  268. }
  269. if catalog == nil {
  270. // The profile stores effect names and a board without a catalog has none
  271. // to store, so every channel is written as "unknown" and cannot be
  272. // restored. Say so here, where the user can still act on it. The way out
  273. // names the zone, because the command takes it as its first argument and
  274. // an index on its own would be read as a channel name.
  275. fmt.Fprintf(warn,
  276. "Warning: this keyboard has no effect names, so the profile records effect %q and cannot restore it; "+
  277. "run `keyboard fetch` for its VIA definition, or set an effect with `effect <zone> <index>`\n", "unknown")
  278. }
  279. p := &Profile{
  280. Name: name,
  281. Version: 1,
  282. Board: boardFor(target.Device.VendorID, target.Device.ProductID),
  283. }
  284. if err := applyProfileToProfile(proto, channels, target.Display, catalog, p); err != nil {
  285. return err
  286. }
  287. if isPath {
  288. return p.saveTo(path)
  289. }
  290. return p.Save()
  291. }
  292. // savedProfileLine is what a save says it did. The path is annotated the way every
  293. // other message here is, and the name is in the line because the file is named
  294. // after it: a save that only reported success would be silent about where a file
  295. // it was told to write by path actually went, which is the one thing about a save
  296. // worth reporting.
  297. func savedProfileLine(name, path string) string {
  298. return fmt.Sprintf("Saved profile %s to %s\n", name, describeDataDir(path))
  299. }
  300. // deletedProfileLine is what a delete says it did, in the same words as the save:
  301. // the name and the file it removed, and the file annotated as the user's when that
  302. // is where it was. A delete that printed nothing is a command that has run and
  303. // cannot be told apart from one that removed something else.
  304. func deletedProfileLine(name, path string) string {
  305. return fmt.Sprintf("Deleted profile %s from %s\n", name, describeDataDir(path))
  306. }
  307. func NewProfileSaveCmd() *cobra.Command {
  308. return &cobra.Command{
  309. Use: "save [name|file]",
  310. Short: "Save current RGB state to a profile",
  311. Long: "Read the current RGB settings from every channel of the keyboard and save\n" +
  312. "them as a JSON profile. A name is written as <name>.json in the per-user\n" +
  313. "profiles/ directory, which is created if it is not there yet; an argument\n" +
  314. "ending in .json is a path, and that file is written where it says, in a\n" +
  315. "directory that has to exist already. It reports the file it wrote.\n" +
  316. "\n" +
  317. "A profile is always complete. Recording only some of the channels would let a\n" +
  318. "later `load` apply them and leave the rest as they were, which reads as a\n" +
  319. "zone the profile had nothing to say about.",
  320. Args: cobra.MaximumNArgs(1),
  321. RunE: func(cmd *cobra.Command, args []string) error {
  322. arg := "default"
  323. if len(args) > 0 {
  324. arg = args[0]
  325. }
  326. path, name, isPath := resolveProfileTarget(arg)
  327. if err := loadProfileFromDevice(path, name, isPath, cmd.ErrOrStderr()); err != nil {
  328. return err
  329. }
  330. fmt.Fprint(cmd.OutOrStdout(), savedProfileLine(name, path))
  331. return nil
  332. },
  333. }
  334. }
  335. func NewProfileLoadCmd() *cobra.Command {
  336. // Not withZoneArgs: the profile name comes first and the zone second, so
  337. // the zone completion only applies once the name has been typed.
  338. cmd := &cobra.Command{
  339. ValidArgsFunction: completeLoadArgs,
  340. Use: "load <name|file> [zone]",
  341. Short: "Load a profile and apply it to the keyboard",
  342. Long: "Read a JSON profile and apply the saved RGB settings to the keyboard. A\n" +
  343. "name is read from the per-user profiles/ directory; an argument ending in\n" +
  344. ".json is a path, and that file is read where it says. Without a zone the\n" +
  345. "profile is applied to every channel it names.",
  346. RunE: func(cmd *cobra.Command, args []string) error {
  347. arg := args[0]
  348. zone := ""
  349. if len(args) == 2 {
  350. zone = args[1]
  351. }
  352. proto, target, selected, err := openTarget(zone)
  353. if err != nil {
  354. return err
  355. }
  356. defer proto.Close()
  357. p, err := LoadProfile(arg)
  358. if err != nil {
  359. return err
  360. }
  361. // A profile belongs to the keyboard it was saved from: its effect
  362. // names are that board's. Applying it elsewhere is allowed, because
  363. // the names that do not exist are reported per key below, but it is
  364. // said out loud, since a mismatch is the likeliest reason.
  365. if p.MatchesBoard(target.Device.VendorID, target.Device.ProductID) == boardOther {
  366. fmt.Fprintln(cmd.ErrOrStderr(), p.boardMismatchWarning(target.Device.VendorID, target.Device.ProductID))
  367. }
  368. catalog, _, err := resolveCatalog(target)
  369. if err != nil {
  370. return err
  371. }
  372. keys := make([]string, 0, len(p.Zones))
  373. for key := range p.Zones {
  374. keys = append(keys, key)
  375. }
  376. sort.Strings(keys)
  377. // A key is a channel name as the file wrote it. Resolving it here
  378. // means a profile written before a board was renamed reports the
  379. // key it cannot place instead of silently applying nothing.
  380. selectedSet := make(map[via.Channel]bool, len(selected))
  381. for _, ch := range selected {
  382. selectedSet[ch] = true
  383. }
  384. // Presence and selection are two different questions. A key the
  385. // selection leaves out is the user's own choice and stays quiet; a
  386. // key naming a channel the keyboard does not have has nowhere to go
  387. // and is reported, whatever the selection says.
  388. present, err := proto.DetectChannels()
  389. if err != nil {
  390. return err
  391. }
  392. presentSet := make(map[via.Channel]bool, len(present))
  393. for _, ch := range present {
  394. presentSet[ch] = true
  395. }
  396. // Without a catalog there are no effect names to look the
  397. // profile's value up in, so the whole load is impossible. Say that
  398. // once instead of reporting every key's name as not found.
  399. if catalog == nil {
  400. fmt.Fprintf(cmd.ErrOrStderr(),
  401. "Warning: profile %q has no effect names for this keyboard, so nothing applied; "+
  402. "run `keyboard fetch` for its VIA definition\n", arg)
  403. return nil
  404. }
  405. applied := 0
  406. for _, key := range keys {
  407. settings := p.Zones[key]
  408. keyChannels, err := resolveZoneName(key, target.Display, target.Alternatives)
  409. if err != nil {
  410. fmt.Fprintf(cmd.ErrOrStderr(),
  411. "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", arg, key)
  412. continue
  413. }
  414. placed := false
  415. for _, ch := range keyChannels {
  416. if presentSet[ch] {
  417. placed = true
  418. break
  419. }
  420. }
  421. if !placed {
  422. fmt.Fprintf(cmd.ErrOrStderr(),
  423. "Warning: profile %q names zone %q, which this keyboard does not have; skipping\n", arg, key)
  424. continue
  425. }
  426. for _, ch := range keyChannels {
  427. // A key is applied only where the selection allows it, so
  428. // `load <name> logo` leaves the other channels alone.
  429. if !selectedSet[ch] {
  430. continue
  431. }
  432. // A zone the caller named is an explicit request for it, so an
  433. // effect the channel does not have is said rather than skipped;
  434. // without one the whole profile is being applied and a key the
  435. // board cannot do is worth a warning instead of a refusal.
  436. targets, _, err := intrgb.ResolveEffect(catalog, settings.Effect, []via.Channel{ch}, zone != "")
  437. if err != nil {
  438. fmt.Fprintf(cmd.ErrOrStderr(),
  439. "Warning: effect %q not found on %s, skipping\n", settings.Effect, channelName(ch, target.Display))
  440. continue
  441. }
  442. // ResolveEffect can answer with nothing to do and no
  443. // error: the name exists on the board but not on this
  444. // channel, and no zone was named, so it is skipped. Indexing
  445. // its result would crash here instead of saying so.
  446. if len(targets) == 0 {
  447. fmt.Fprintf(cmd.ErrOrStderr(),
  448. "Warning: effect %q not found on %s, skipping\n", settings.Effect, channelName(ch, target.Display))
  449. continue
  450. }
  451. if err := proto.SetValue(ch, uint8(intrgb.EffectID), targets[0].ID); err != nil {
  452. return err
  453. }
  454. if err := proto.SetValue(ch, uint8(intrgb.Brightness), settings.Brightness); err != nil {
  455. return err
  456. }
  457. if err := proto.SetValue(ch, uint8(intrgb.Speed), settings.Speed); err != nil {
  458. return err
  459. }
  460. if settings.Enabled && settings.Color != "" {
  461. hue, sat, err := hexToHSV(settings.Color)
  462. if err != nil {
  463. fmt.Fprintf(cmd.ErrOrStderr(),
  464. "Warning: invalid color %q on zone %s, skipping\n", settings.Color, channelName(ch, target.Display))
  465. } else if err := proto.SetColor(ch, hue, sat); err != nil {
  466. return err
  467. }
  468. }
  469. applied++
  470. }
  471. }
  472. if applied == 0 {
  473. fmt.Fprintf(cmd.ErrOrStderr(),
  474. "Warning: profile %q has no settings for the selected zone(s); nothing applied\n", arg)
  475. }
  476. return nil
  477. },
  478. }
  479. cmd.Args = zoneArgs(1, 2, "a profile name or file and at most a zone")
  480. return cmd
  481. }
  482. func NewProfileListCmd() *cobra.Command {
  483. return &cobra.Command{
  484. Use: "list",
  485. Short: "List saved profiles",
  486. Args: cobra.NoArgs,
  487. RunE: func(cmd *cobra.Command, args []string) error {
  488. names, err := ListProfiles()
  489. if err != nil {
  490. return err
  491. }
  492. if names == nil {
  493. names = []string{}
  494. }
  495. lines := make([]string, 0, len(names))
  496. for _, name := range names {
  497. p, err := LoadProfile(name)
  498. if err != nil {
  499. // A file that cannot be read is still a name in the
  500. // directory; the name is what the user can act on.
  501. lines = append(lines, name)
  502. continue
  503. }
  504. lines = append(lines, p.listLine())
  505. }
  506. if jsonOutput {
  507. return encodeJSON(cmd.OutOrStdout(), struct {
  508. Profiles []string `json:"profiles"`
  509. }{Profiles: names})
  510. }
  511. for _, line := range lines {
  512. fmt.Fprintln(cmd.OutOrStdout(), line)
  513. }
  514. return nil
  515. },
  516. }
  517. }
  518. func NewProfileDeleteCmd() *cobra.Command {
  519. cmd := &cobra.Command{
  520. ValidArgsFunction: completeProfileNames,
  521. Use: "delete [name]",
  522. Short: "Delete a saved profile",
  523. Long: "Delete a profile from the per-user profiles/ directory, by name; without a\n" +
  524. "name it deletes `default`. A path is not accepted, so an argument ending in\n" +
  525. ".json names the profile without the dots rather than a file to remove. It\n" +
  526. "reports the file it removed.",
  527. Args: cobra.MaximumNArgs(1),
  528. RunE: func(cmd *cobra.Command, args []string) error {
  529. name := "default"
  530. if len(args) > 0 {
  531. name = args[0]
  532. }
  533. path, err := DeleteProfile(name)
  534. if err != nil {
  535. return err
  536. }
  537. fmt.Fprint(cmd.OutOrStdout(), deletedProfileLine(name, path))
  538. return nil
  539. },
  540. }
  541. return cmd
  542. }
  543. func hexToHSV(s string) (uint8, uint8, error) {
  544. if len(s) != 4 {
  545. return 0, 0, fmt.Errorf("invalid HSV hex (expected 4 hex digits): %s", s)
  546. }
  547. var hue, sat uint8
  548. _, err := fmt.Sscanf(s, "%02x%02x", &hue, &sat)
  549. return hue, sat, err
  550. }