package main // openrazer as the tools see it: the kernel driver (built by DKMS, loaded, holding devices), the // account's membership of the group the driver gives its devices to, and the daemon in the account's // service manager, asked over the session bus. Every bus call is made with --auto-start=no: the daemon // is D-Bus activatable, and a question must never start it. import ( "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" ) const ( dkmsName = "openrazer-driver" group = "openrazer" daemonComm = "openrazer-daemo" // the kernel keeps 15 characters of openrazer-daemon daemonUnit = "openrazer-daemon.service" busName = "org.razer" busRoot = "/org/razer" activation = "/usr/share/dbus-1/services/org.razer.service" // mostDevices bounds the questions one answer asks the daemon. mostDevices = 16 ) // packages are the module's, from the official repositories: the driver's DKMS source, the daemon, // and the Python library every front end (the tray among them) speaks to the daemon through. var packages = []string{"openrazer-driver-dkms", "openrazer-daemon", "python-openrazer"} // kernelModules are what the DKMS package builds (its dkms.conf). var kernelModules = []string{"razerkbd", "razermouse", "razerkraken", "razeraccessory"} // hidID is a HID device's name under its driver: bus:vendor:product.instance. // stamp is the daemon's own time at the front of its log lines, dropped so repeats are seen as one. var stamp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s*\|\s*`) var hidID = regexp.MustCompile(`^[0-9A-Fa-f]{4}:([0-9A-Fa-f]{4}):([0-9A-Fa-f]{4})\.[0-9A-Fa-f]{4}$`) // Bound is one device the driver holds: its USB id, the driver module and how many of its HID // interfaces are bound. type Bound struct { USB string `json:"usb"` Driver string `json:"driver"` Interfaces int `json:"interfaces"` } // Driver is the kernel side. type Driver struct { Kernel string `json:"running_kernel"` // Built is DKMS's word for the driver and the running kernel: "installed" when it is built and // installed for it, "" when DKMS has nothing for it. Built string `json:"built,omitempty"` Loaded []string `json:"loaded"` Bound []Bound `json:"bound"` } // Group is the account's membership of the group the driver's udev rules give the devices to. type Group struct { Name string `json:"name"` Exists bool `json:"exists"` Account bool `json:"account_in_group"` // Manager is whether the account's running service manager has the group. It took its groups when // it started, at the account's first login, and the daemon runs under it. Manager *bool `json:"service_manager_has_it,omitempty"` } // Device is one device as the daemon sees it. Its serial is not answered. type Device struct { Name string `json:"name"` Type string `json:"type,omitempty"` Firmware string `json:"firmware,omitempty"` Battery *float64 `json:"battery_percent,omitempty"` Charging *bool `json:"charging,omitempty"` } // Daemon is the account's daemon. type Daemon struct { Active string `json:"unit_active"` Enabled string `json:"unit_enabled"` Running []Proc `json:"running"` // StartedBy is what starts it: D-Bus activation of org.razer, through its unit. StartedBy string `json:"started_by"` Version string `json:"version,omitempty"` Unanswered string `json:"unanswered,omitempty"` LastWords []string `json:"last_words,omitempty"` } // StatusAnswer is what openrazer_status answers. type StatusAnswer struct { Installed map[string]string `json:"installed"` Driver Driver `json:"driver"` Group Group `json:"group"` Daemon Daemon `json:"daemon"` Devices []Device `json:"devices"` Notes []string `json:"notes,omitempty"` } // busCall asks the daemon one question and answers busctl's data. func (m *Machine) busCall(path, iface, method string) (json.RawMessage, error) { args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, path, iface, method} o := m.cmd(5*time.Second, m.bus(), "busctl", args...) if err := failed(o, "busctl", args...); err != nil { return nil, err } var doc struct { Data []json.RawMessage `json:"data"` } if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 { return nil, fmt.Errorf("the daemon's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200)) } return doc.Data[0], nil } func (m *Machine) busString(path, iface, method string) (string, error) { raw, err := m.busCall(path, iface, method) if err != nil { return "", err } var s string if err := json.Unmarshal(raw, &s); err != nil { return "", fmt.Errorf("the daemon's %s is not a string: %w", method, err) } return s, nil } // devices asks the daemon for the devices it holds, and each one's name, type, firmware and battery. func (m *Machine) devices() ([]Device, string, error) { version, err := m.busString(busRoot, "razer.daemon", "version") if err != nil { return nil, "", err } raw, err := m.busCall(busRoot, "razer.devices", "getDevices") if err != nil { return nil, version, err } var serials []string if err := json.Unmarshal(raw, &serials); err != nil { return nil, version, fmt.Errorf("the daemon's device list is not a list of names: %w", err) } if len(serials) > mostDevices { serials = serials[:mostDevices] } out := []Device{} for _, serial := range serials { path := busRoot + "/device/" + serial d := Device{} d.Name, _ = m.busString(path, "razer.device.misc", "getDeviceName") d.Type, _ = m.busString(path, "razer.device.misc", "getDeviceType") d.Firmware, _ = m.busString(path, "razer.device.misc", "getFirmware") if raw, err := m.busCall(path, "razer.device.power", "getBattery"); err == nil { var pct float64 if json.Unmarshal(raw, &pct) == nil { d.Battery = &pct } } if raw, err := m.busCall(path, "razer.device.power", "isCharging"); err == nil { var on bool if json.Unmarshal(raw, &on) == nil { d.Charging = &on } } if d.Name == "" { d.Name = "(unnamed)" } out = append(out, d) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out, version, nil } // driver reads the kernel side: DKMS's state for the running kernel, the loaded modules, and the // devices bound to them. func (m *Machine) driver() Driver { d := Driver{Kernel: readTrimmed(m.path("/proc/sys/kernel/osrelease")), Loaded: []string{}, Bound: []Bound{}} if d.Kernel != "" { o := m.cmd(0, nil, "dkms", "status", dkmsName, "-k", d.Kernel) for _, l := range strings.Split(o.Stdout, "\n") { if strings.HasPrefix(l, dkmsName+"/") && strings.Contains(l, d.Kernel) { if i := strings.LastIndex(l, ":"); i > 0 { d.Built = strings.TrimSpace(l[i+1:]) } } } } for _, k := range kernelModules { if exists(m.path(filepath.Join("/sys/module", k))) { d.Loaded = append(d.Loaded, k) } } drivers, _ := os.ReadDir(m.path("/sys/bus/hid/drivers")) seen := map[string]int{} for _, drv := range drivers { if !strings.HasPrefix(drv.Name(), "razer") { continue } entries, _ := os.ReadDir(m.path(filepath.Join("/sys/bus/hid/drivers", drv.Name()))) for _, e := range entries { if f := hidID.FindStringSubmatch(e.Name()); f != nil { usb := strings.ToLower(f[1] + ":" + f[2]) if i, ok := seen[drv.Name()+" "+usb]; ok { d.Bound[i].Interfaces++ continue } seen[drv.Name()+" "+usb] = len(d.Bound) d.Bound = append(d.Bound, Bound{USB: usb, Driver: drv.Name(), Interfaces: 1}) } } } return d } // account is the operator account's name, from the user database by uid. func (m *Machine) account() string { raw, _ := readBounded(m.path("/etc/passwd")) for _, l := range strings.Split(string(raw), "\n") { f := strings.Split(l, ":") if len(f) > 2 && f[2] == strconv.Itoa(m.UID) { return f[0] } } return "" } // groupOf reads one group's gid and members from the group file. func (m *Machine) groupOf(name string) (gid int, members []string, found bool) { raw, _ := readBounded(m.path("/etc/group")) for _, l := range strings.Split(string(raw), "\n") { f := strings.Split(l, ":") if len(f) == 4 && f[0] == name { gid, _ = strconv.Atoi(f[2]) for _, who := range strings.Split(f[3], ",") { if who = strings.TrimSpace(who); who != "" { members = append(members, who) } } return gid, members, true } } return 0, nil, false } func contains(list []string, s string) bool { for _, x := range list { if x == s { return true } } return false } // managerGroups are the supplementary groups of the account's own service manager; ok is false when // it does not run. func (m *Machine) managerGroups() (groups []int, ok bool) { for _, p := range m.procs("systemd") { dir := m.path(filepath.Join("/proc", strconv.Itoa(p.PID))) if !strings.Contains(readTrimmed(filepath.Join(dir, "cgroup")), fmt.Sprintf("user@%d.service", m.UID)) { continue } for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") { if f := strings.Fields(l); len(f) > 0 && f[0] == "Groups:" { for _, g := range f[1:] { if n, err := strconv.Atoi(g); err == nil { groups = append(groups, n) } } } } return groups, true } return nil, false } func (m *Machine) group() Group { g := Group{Name: group} gid, members, found := m.groupOf(group) if !found { return g } g.Exists = true g.Account = contains(members, m.account()) if in, ok := m.managerGroups(); ok { has := false for _, n := range in { has = has || n == gid } g.Manager = &has } return g } // unit answers the daemon's unit's active and enabled states in the account's service manager. func (m *Machine) unit() (active, enabled string) { word := func(verb string) string { o := m.cmd(5*time.Second, m.bus(), "systemctl", "--user", verb, daemonUnit) if o.Err != nil { return "unknown" } if w := strings.TrimSpace(o.Stdout); w != "" { return strings.Fields(w)[0] } return "unknown" } return word("is-active"), word("is-enabled") } // lastWords are the daemon's last lines in the account's journal, for a unit that failed: its critical // and error lines when it wrote any (the reason, not the traceback of its exit), else the last lines. func (m *Machine) lastWords() []string { o := m.cmd(5*time.Second, m.bus(), "journalctl", "--user", "-u", daemonUnit, "-n", "20", "-o", "cat", "--no-pager") var all, said []string seen := map[string]bool{} for _, l := range strings.Split(o.Stdout, "\n") { if l = strings.TrimSpace(l); l == "" { continue } l = tail(stamp.ReplaceAllString(l, ""), 300) all = append(all, l) if (strings.Contains(l, "CRITICAL") || strings.Contains(l, "ERROR")) && !seen[l] { seen[l] = true said = append(said, l) } } if len(said) > 0 { return said } return lastOf(all, 4) } func (m *Machine) daemon() (Daemon, []Device) { d := Daemon{Running: m.procs(daemonComm), StartedBy: "D-Bus activation of " + busName + " through " + daemonUnit} if d.Running == nil { d.Running = []Proc{} } if !exists(m.path(activation)) { d.StartedBy = "nothing: the package's activation file " + activation + " is missing" } d.Active, d.Enabled = m.unit() if d.Active == "failed" { d.LastWords = m.lastWords() } devices := []Device{} if len(d.Running) == 0 { d.Unanswered = "the daemon is not running (asked without starting it)" return d, devices } got, version, err := m.devices() d.Version = version if err != nil { d.Unanswered = err.Error() return d, devices } return d, got } // Status reads the whole stack. func (m *Machine) Status() (StatusAnswer, error) { s := StatusAnswer{Installed: map[string]string{}, Driver: m.driver(), Group: m.group()} for _, p := range packages { v, err := m.installed(p) if err != nil { return s, err } s.Installed[p] = v } s.Daemon, s.Devices = m.daemon() if _, members, ok := m.groupOf("plugdev"); ok && contains(members, m.account()) { s.Notes = append(s.Notes, "the account is in plugdev, which this openrazer does not use: its udev rules "+ "give the devices to the openrazer group. Other devices' rules may use plugdev; the module leaves it") } return s, nil } // RestartAnswer is what openrazer_restart answers. type RestartAnswer struct { Active string `json:"unit_active"` Running []Proc `json:"running"` Version string `json:"version,omitempty"` Devices []Device `json:"devices"` } // Restart restarts the daemon's unit in the account's service manager and waits for it to answer. func (m *Machine) Restart() (RestartAnswer, error) { a := RestartAnswer{Devices: []Device{}} args := []string{"--user", "restart", daemonUnit} if err := failed(m.cmd(15*time.Second, m.bus(), "systemctl", args...), "systemctl", args...); err != nil { words := m.lastWords() return a, fmt.Errorf("the daemon did not start: %v. Its last words: %s. See openrazer_check", err, strings.Join(words, " | ")) } a.Active, _ = m.unit() a.Running = m.waitFor(daemonComm, 4*time.Second) devices, version, err := m.devices() a.Version = version if err != nil { return a, fmt.Errorf("the daemon started but does not answer: %v", err) } a.Devices = devices return a, nil } // CheckAnswer is what openrazer_check answers. type CheckAnswer struct { OK bool `json:"ok"` Findings []Finding `json:"findings"` Starts []string `json:"starts"` } // Check verifies the stack from the packages to the devices the daemon sees. func (m *Machine) Check() (CheckAnswer, error) { a := CheckAnswer{Findings: []Finding{}, Starts: []string{}} add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) } for _, p := range packages { v, err := m.installed(p) if err != nil { return a, err } if v == "" { add("the package "+p+" is not installed", "push the module to the node") } } d := m.driver() switch { case d.Kernel == "": add("the running kernel's release is unreadable", "") case d.Built != "installed": add("the driver is not built for the running kernel "+d.Kernel+" (DKMS: "+orNone(d.Built)+")", "install the headers of the running kernel (linux-headers), then `sudo dkms autoinstall`; or reboot into the kernel it was built for") } if len(d.Bound) == 0 { add("no Razer device is bound to the driver", "plug the device in; if it is plugged in, openrazer_status shows which driver modules are loaded") } if len(d.Loaded) == 0 && d.Built == "installed" { add("none of the driver's modules is loaded", "plug the device in, which loads its module, or `sudo modprobe razermouse` (razerkbd, razerkraken, razeraccessory)") } g := m.group() switch { case !g.Exists: add("the group openrazer does not exist", "it comes with openrazer-driver-dkms (sysusers): reinstall it, or `sudo systemd-sysusers`") case !g.Account: add("the account is not in the openrazer group: the daemon refuses to start, and the devices' files are the group's", "`sudo gpasswd -a $USER openrazer`, then log out of every session (or reboot)") case g.Manager != nil && !*g.Manager: add("the account is in the openrazer group, but its running service manager started before it was", "log out of every session (or reboot), so the service manager starts again with the group") } if exists(m.path(activation)) { a.Starts = append(a.Starts, "D-Bus activation: "+busName+" through "+daemonUnit) } else { add("the package's activation file "+activation+" is missing: nothing starts the daemon", "reinstall openrazer-daemon") } for _, l := range m.i3Starts("openrazer-daemon") { a.Starts = append(a.Starts, "window manager: "+l) add("a second start: "+l, "remove the line; D-Bus activation starts the daemon when a client asks") } daemon, devices := m.daemon() switch { case len(daemon.Running) == 0 && daemon.Active == "failed": add("the daemon failed at its last start: "+strings.Join(lastOf(daemon.LastWords, 2), " | "), "fix what it says, then openrazer_restart") case len(daemon.Running) == 0: add("the daemon is not running", "openrazer_restart; at login the tray's helper asks for it, which starts it") case len(daemon.Running) > 1: add(fmt.Sprintf("%d daemons run", len(daemon.Running)), "openrazer_restart ends them and starts one, from its unit") default: if in := daemon.Running[0].StartedIn; in != daemonUnit { add("the daemon runs outside its unit (in "+in+"): started by hand or by a second start", "openrazer_restart") } } if daemon.Unanswered != "" && len(daemon.Running) > 0 { add("the daemon does not answer: "+daemon.Unanswered, "openrazer_restart") } if len(daemon.Running) > 0 && daemon.Unanswered == "" && len(devices) < len(d.Bound) { add(fmt.Sprintf("the driver holds %d device(s) and the daemon sees %d", len(d.Bound), len(devices)), "openrazer_restart") } a.OK = len(a.Findings) == 0 return a, nil } func orNone(s string) string { if s == "" { return "nothing for it" } return s } func lastOf(lines []string, n int) []string { if len(lines) <= n { return lines } return lines[len(lines)-n:] }