package main import ( "fmt" "strconv" "strings" intdevice "netdome.biz/paul/qmk-rgb/internal/device" intrgb "netdome.biz/paul/qmk-rgb/internal/rgb" "netdome.biz/paul/qmk-rgb/internal/via" ) type zoneProtocol interface { SetValue(via.Channel, uint8, uint8) error SetColor(via.Channel, uint8, uint8) error } type rgbProtocol interface { zoneProtocol GetValue(via.Channel, uint8) ([]byte, error) DetectChannels() ([]via.Channel, error) Close() error } // targetDeviceData is everything a command needs before it opens the keyboard. type targetDeviceData struct { Device intdevice.Device Display map[uint16]string // Alternatives are the names a channel also answers to besides the one its // definition gives it, so a name the user already knows keeps working. Alternatives map[uint16][]string // Requested is nil when no --zone was given, which means every channel // the keyboard has. Requested []via.Channel } // prepareTarget resolves the keyboard, its display names and the requested // channels. Enumeration does not open a HID handle, so an unusable --zone value // is still rejected before the device is opened. var prepareTarget = func() (targetDeviceData, error) { devices, err := discoverAll() if err != nil { return targetDeviceData{}, fmt.Errorf("discover: %w", err) } dev, err := selectDevice(devices, targetDevice) if err != nil { return targetDeviceData{}, err } // A channel is named by the definition file that describes the board, because // those are the names VIA shows. Without a definition the QMK subsystem name // follows from the channel number and is all there is. display, alternatives := applyDefinitionLabels(dev.VendorID, dev.ProductID) requested, err := resolveZoneName(targetZone, display, alternatives) if err != nil { return targetDeviceData{}, err } dev.Name = boardName(dev, display) return targetDeviceData{ Device: dev, Display: display, Alternatives: alternatives, Requested: requested, }, nil } func forEachChannel(channels []via.Channel, fn func(via.Channel) error) error { for _, ch := range channels { if err := fn(ch); err != nil { return err } } return nil } func setValueOnChannels(proto zoneProtocol, channels []via.Channel, param, value uint8) error { return forEachChannel(channels, func(ch via.Channel) error { return proto.SetValue(ch, param, value) }) } // zoneResult records what was requested for a zone and what the keyboard // reported afterwards. The two differ on real hardware: the Impact 80 clamps // brightness at 160 on the logo and side channels and scales it up to 255 on // the backlight channel. type zoneResult struct { Name string Requested uint8 Applied uint8 } // Mismatch reports whether the keyboard applied something other than the // requested value. func (r zoneResult) Mismatch() bool { return r.Applied != r.Requested } // anyMismatch reports whether any zone deviated from the request. func anyMismatch(results []zoneResult) bool { for _, r := range results { if r.Mismatch() { return true } } return false } // formatResults renders one line stating what each selected zone actually // holds, so a partial application is visible instead of being summarised as // the value that was asked for. func formatResults(label string, results []zoneResult) string { var b strings.Builder b.WriteString(label) for _, r := range results { fmt.Fprintf(&b, " %s %d", r.Name, r.Applied) } if len(results) > 0 { fmt.Fprintf(&b, " (requested %d)", results[0].Requested) } return b.String() } // readBackValues reads one value ID from every channel, in the order given. // Every verified set reads back through this, so a parameter that needs no new // read-back path reuses it. func readBackValues(proto rgbProtocol, channels []via.Channel, display map[uint16]string, param uint8) ([]uint8, error) { values := make([]uint8, 0, len(channels)) for _, ch := range channels { raw, err := proto.GetValue(ch, param) if err != nil { return nil, fmt.Errorf("read back value 0x%02x for %s: %w", param, channelName(ch, display), err) } if len(raw) == 0 { return nil, fmt.Errorf("read back value 0x%02x for %s: empty response", param, channelName(ch, display)) } values = append(values, raw[0]) } return values, nil } // setValueVerified writes one value ID to every zone and reads each back, so a // clamped or rescaled value is reported instead of silently claimed as set. // The firmware transform differs per channel and per value ID, so the read-back // is not optional for any parameter the keyboard rescales. func setValueVerified(proto rgbProtocol, channels []via.Channel, display map[uint16]string, param, value uint8) ([]zoneResult, error) { if err := setValueOnChannels(proto, channels, param, value); err != nil { return nil, err } applied, err := readBackValues(proto, channels, display, param) if err != nil { return nil, err } results := make([]zoneResult, 0, len(channels)) for i, ch := range channels { results = append(results, zoneResult{Name: channelName(ch, display), Requested: value, Applied: applied[i]}) } return results, nil } // effectResult records the effect a zone was asked for and the one the keyboard // holds afterwards. The Impact 80's logo and side channels do not take effect ID // 0: the firmware reads it as "lighting off" and leaves the mode register where // it was, so the effect that is still running has to be named rather than // reported as the requested one. type effectResult struct { Name string RequestedName string RequestedID uint8 AppliedName string AppliedID uint8 } // Mismatch reports whether the keyboard holds an effect other than the one // requested. func (r effectResult) Mismatch() bool { return r.AppliedID != r.RequestedID } // anyEffectMismatch reports whether any zone kept an effect of its own. func anyEffectMismatch(results []effectResult) bool { for _, r := range results { if r.Mismatch() { return true } } return false } // formatEffectResults names the effect each selected zone actually holds, so an // effect the keyboard refused is visible instead of being summarised as the one // that was asked for. func formatEffectResults(requestedName string, results []effectResult) string { var b strings.Builder b.WriteString("Effect") for _, r := range results { fmt.Fprintf(&b, " %s %q (%d)", r.Name, r.AppliedName, r.AppliedID) } if len(results) > 0 { fmt.Fprintf(&b, " (requested %q (%d))", requestedName, results[0].RequestedID) } return b.String() } // setEffectVerified writes one effect ID per channel and reads every channel // back. It cannot reuse setValueVerified, because one effect name is a // different index on each subsystem, so the requested value is carried per // target while the read-back stays shared. func setEffectVerified(proto rgbProtocol, targets []intrgb.EffectTarget, display map[uint16]string, catalog *intrgb.Catalog) ([]effectResult, error) { channels := make([]via.Channel, 0, len(targets)) requested := make(map[via.Channel]uint8, len(targets)) for _, t := range targets { if err := proto.SetValue(t.Channel, uint8(intrgb.EffectID), t.ID); err != nil { return nil, fmt.Errorf("set effect on %s: %w", channelName(t.Channel, display), err) } channels = append(channels, t.Channel) requested[t.Channel] = t.ID } applied, err := readBackValues(proto, channels, display, uint8(intrgb.EffectID)) if err != nil { return nil, err } results := make([]effectResult, 0, len(channels)) for i, ch := range channels { results = append(results, effectResult{ Name: channelName(ch, display), RequestedName: catalog.EffectName(ch, requested[ch]), RequestedID: requested[ch], AppliedName: catalog.EffectName(ch, applied[i]), AppliedID: applied[i], }) } return results, nil } // setBrightnessVerified writes the brightness and reads every channel back. func setBrightnessVerified(proto rgbProtocol, channels []via.Channel, display map[uint16]string, value uint8) ([]zoneResult, error) { return setValueVerified(proto, channels, display, uint8(intrgb.Brightness), value) } // setSpeedVerified writes the speed and reads every channel back. func setSpeedVerified(proto rgbProtocol, channels []via.Channel, display map[uint16]string, value uint8) ([]zoneResult, error) { return setValueVerified(proto, channels, display, uint8(intrgb.Speed), value) } // formatEffectIDResults renders the same line as formatEffectResults, with the // numbers: the request was a number, and a board without a definition has no name // to put in its place. func formatEffectIDResults(results []effectResult) string { var b strings.Builder b.WriteString("Effect") for _, r := range results { fmt.Fprintf(&b, " %s %d", r.Name, r.AppliedID) } if len(results) > 0 { fmt.Fprintf(&b, " (requested %d)", results[0].RequestedID) } return b.String() } // colorResult records the hue and saturation one channel holds after a color was // written to it. The color value ID carries two bytes, so both components are // read back: a keyboard that stored another saturation must not be reported as // having taken the requested color. type colorResult struct { Name string RequestedHue uint8 RequestedSaturation uint8 Hue uint8 Saturation uint8 } // Mismatch reports whether the keyboard stored something other than the // requested color. func (r colorResult) Mismatch() bool { return r.Hue != r.RequestedHue || r.Saturation != r.RequestedSaturation } // setColorVerified writes one hue and saturation to every channel and reads each // back, for the same reason setValueVerified exists: the command must report // what the keyboard holds, not what it was asked for. func setColorVerified(proto rgbProtocol, channels []via.Channel, display map[uint16]string, hue, saturation uint8) ([]colorResult, error) { if err := setColorOnChannels(proto, channels, hue, saturation); err != nil { return nil, err } results := make([]colorResult, 0, len(channels)) for _, ch := range channels { raw, err := proto.GetValue(ch, uint8(intrgb.ColorValue)) if err != nil { return nil, fmt.Errorf("read back color for %s: %w", channelName(ch, display), err) } if len(raw) < 2 { return nil, fmt.Errorf("read back color for %s: got %d bytes, want hue and saturation", channelName(ch, display), len(raw)) } results = append(results, colorResult{ Name: channelName(ch, display), RequestedHue: hue, RequestedSaturation: saturation, Hue: raw[0], Saturation: raw[1], }) } return results, nil } func setColorOnChannels(proto zoneProtocol, channels []via.Channel, hue, saturation uint8) error { return forEachChannel(channels, func(ch via.Channel) error { return proto.SetColor(ch, hue, saturation) }) } func setBrightnessOnChannels(proto zoneProtocol, channels []via.Channel, value uint8) error { return setValueOnChannels(proto, channels, uint8(intrgb.Brightness), value) } func setSpeedChannels(proto zoneProtocol, channels []via.Channel, value uint8) error { return setValueOnChannels(proto, channels, uint8(intrgb.Speed), value) } func disableLightingOnChannels(proto zoneProtocol, channels []via.Channel) error { return forEachChannel(channels, func(ch via.Channel) error { if err := proto.SetValue(ch, uint8(intrgb.EffectID), 0); err != nil { return err } return proto.SetValue(ch, uint8(intrgb.Brightness), 0) }) } func enableLightingOnChannels(proto zoneProtocol, channels []via.Channel, catalog *intrgb.Catalog) error { return forEachChannel(channels, func(ch via.Channel) error { effect, ok := catalog.DefaultEffect(ch) if !ok { return fmt.Errorf("no default effect for %s", channelName(ch, nil)) } if err := proto.SetValue(ch, uint8(intrgb.EffectID), effect); err != nil { return err } return proto.SetValue(ch, uint8(intrgb.Brightness), 160) }) } // openTarget opens the keyboard and resolves the requested channels against the // ones it actually has. It is a seam because a command needs all three: the // handle it writes to, the names it reports with, and the channels it may touch. var openTarget = func() (rgbProtocol, targetDeviceData, []via.Channel, error) { target, err := prepareTarget() if err != nil { return nil, targetDeviceData{}, nil, err } proto, err := via.New(target.Device) if err != nil { return nil, targetDeviceData{}, nil, fmt.Errorf("open protocol: %w", err) } channels, err := resolveChannels(proto, target) if err != nil { proto.Close() return nil, targetDeviceData{}, nil, err } return proto, target, channels, nil } // resolveChannels intersects the requested channels with the detected ones, and // refuses a name that resolves to a channel this keyboard does not have. func resolveChannels(proto rgbProtocol, target targetDeviceData) ([]via.Channel, error) { present, err := proto.DetectChannels() if err != nil { return nil, err } if err := displayNameConflicts(target.Display, present); err != nil { return nil, err } if target.Requested == nil { if len(present) == 0 { return nil, fmt.Errorf("this keyboard exposes no VIA lighting channels") } return present, nil } presentSet := make(map[via.Channel]bool, len(present)) for _, ch := range present { presentSet[ch] = true } var found []via.Channel for _, ch := range target.Requested { if presentSet[ch] { found = append(found, ch) } } if len(found) == 0 { return nil, fmt.Errorf("channel %s is not present on this keyboard", target.Requested[0].Subsystem()) } return found, nil } // lightingEnabled reports whether a channel is lit: a mode other than none and a // brightness above zero. func lightingEnabled(mode, brightness uint8) bool { return mode != 0 && brightness > 0 } func ParseUint8(s string) (uint8, error) { v, err := strconv.ParseUint(s, 10, 8) if err != nil { return 0, fmt.Errorf("invalid value: %w", err) } return uint8(v), nil }