feat: serial:/size: external matching, exclude Hyprland FALLBACK output
- external list now accepts serial: and size: prefixes; desc: matches serial too. Fixes docked-mode deadlock where Hyprland's synthetic FALLBACK output (created when the built-in is disabled by a stale docked config) was treated as a real external monitor, leaving the daemon stuck in docked after undocking. - modes name resolution gains serial: prefix (resolveSerial). - add -log-level flag for observability. - add unit tests for external matching and name resolution. - update README and example config.
This commit is contained in:
@@ -144,11 +144,15 @@ restore_on_exit: true
|
|||||||
|
|
||||||
# External monitors that trigger docked mode.
|
# External monitors that trigger docked mode.
|
||||||
# Plain name: matches connector (DP-1, HDMI-A-1).
|
# Plain name: matches connector (DP-1, HDMI-A-1).
|
||||||
# desc: prefix: matches by monitor description (survives port rename).
|
# desc: prefix: matches by monitor description or serial (survives port rename).
|
||||||
|
# serial: prefix: matches by serial number.
|
||||||
|
# size: prefix: matches by resolution, optionally with refresh rate (size:2560x1440@165).
|
||||||
# Optional — if omitted or empty, the daemon auto-detects external monitors:
|
# Optional — if omitted or empty, the daemon auto-detects external monitors:
|
||||||
# any display whose connector is not eDP-/LVDS-/DSI- is treated as external.
|
# any display whose connector is not eDP-/LVDS-/DSI- is treated as external.
|
||||||
external: # optional
|
external: # optional
|
||||||
- desc:Dell Inc. DELL U2723QE
|
- desc:Dell Inc. DELL U2723QE
|
||||||
|
- serial:3342300033911
|
||||||
|
- size:3440x1440
|
||||||
- DP-9
|
- DP-9
|
||||||
- DP-10
|
- DP-10
|
||||||
|
|
||||||
@@ -197,19 +201,22 @@ hooks:
|
|||||||
|
|
||||||
### Monitor matching
|
### Monitor matching
|
||||||
|
|
||||||
Three match modes for the `modes` section (the `external` list supports plain names and `desc:`):
|
Match modes for the `modes` section and the `external` list. The `external` list supports all four modes below (plus auto-detect when empty); the `modes` section supports all four as `name:` values:
|
||||||
|
|
||||||
| Syntax | Matches | Use case |
|
| Syntax | Matches | Use case |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DP-1` | Exact connector name | Simple setups, built-in displays |
|
| `DP-1` | Exact connector name | Simple setups, built-in displays |
|
||||||
| `desc:Dell U2723QE` | Substring in monitor description or serial | Survives port rename across different docks |
|
| `desc:Dell U2723QE` | Substring in monitor description or serial | Survives port rename across different docks |
|
||||||
| `size:2560x1440` | Exact pixel dimensions | Two identical monitors with same resolution |
|
| `serial:3342300033911` | Substring in serial number | Uniquely identifies a specific monitor |
|
||||||
|
| `size:2560x1440` | Exact pixel dimensions | Match by resolution instead of connector name |
|
||||||
| `size:2560x1440@165` | Dimensions + refresh rate | Disambiguate identical models |
|
| `size:2560x1440@165` | Dimensions + refresh rate | Disambiguate identical models |
|
||||||
|
|
||||||
Sway description format: `make model serial_widthxheight` (with serial omitted if `Unknown`).
|
Sway description format: `make model serial_widthxheight` (with serial omitted if `Unknown`).
|
||||||
Run `hyprctl monitors all` (Hyprland) or `swaymsg -t get_outputs` (Sway) to see your monitor names and descriptions.
|
Run `hyprctl monitors all` (Hyprland) or `swaymsg -t get_outputs` (Sway) to see your monitor names, descriptions, and serials.
|
||||||
|
|
||||||
**desc: and size: in modes** — when a monitor name in `modes` uses `desc:` or `size:`, the daemon resolves it to the actual connector name at runtime. Ambiguous matches (a prefix matching multiple monitors with identical resolution) cause an error. For `size:`, add `@R` (refresh rate) to disambiguate monitors with the same resolution. Unresolvable names also cause an error.
|
**desc:, serial: and size: in modes** — when a monitor name in `modes` uses `desc:`, `serial:`, or `size:`, the daemon resolves it to the actual connector name at runtime. Ambiguous matches (a prefix matching multiple monitors) cause an error. For `size:`, add `@R` (refresh rate) to disambiguate monitors with the same resolution. Unresolvable names also cause an error.
|
||||||
|
|
||||||
|
**External list** — entries are checked against every detected monitor; a single match triggers docked mode. When the list is empty, any non-internal connector is treated as external.
|
||||||
|
|
||||||
### Backend-specific configuration
|
### Backend-specific configuration
|
||||||
|
|
||||||
@@ -351,20 +358,23 @@ Check the hook command works from a terminal first. Hooks run via `sh -c`, so sh
|
|||||||
|
|
||||||
### Monitor names changed after reboot
|
### Monitor names changed after reboot
|
||||||
|
|
||||||
Use `desc:` or `size:` prefix matching instead of connector names. Both survive port renames across different docks and reboots.
|
Use `desc:`, `serial:`, or `size:` prefix matching instead of connector names. All three survive port renames across different docks and reboots.
|
||||||
|
|
||||||
**Hyprland:** `hyprctl monitors all` to see descriptions.
|
**Hyprland:** `hyprctl monitors all` to see descriptions and serials.
|
||||||
**Sway:** `swaymsg -t get_outputs` to see names, make/model, serial, and native resolution.
|
**Sway:** `swaymsg -t get_outputs` to see names, make/model, serial, and native resolution.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
external:
|
external:
|
||||||
- desc:Dell Inc. DELL U2723QE # survives port rename
|
- desc:Dell Inc. DELL U2723QE # survives port rename
|
||||||
|
- serial:3342300033911 # match a specific monitor by serial
|
||||||
|
|
||||||
modes:
|
modes:
|
||||||
docked:
|
docked:
|
||||||
monitors:
|
monitors:
|
||||||
- name: desc:Dell Inc. DELL U2723QE # also works here
|
- name: desc:Dell Inc. DELL U2723QE # also works here
|
||||||
enabled: true
|
enabled: true
|
||||||
|
- name: serial:3342300033911 # and here
|
||||||
|
enabled: true
|
||||||
- name: size:2560x1440@165 # match by dimensions + refresh
|
- name: size:2560x1440@165 # match by dimensions + refresh
|
||||||
enabled: true
|
enabled: true
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ var (
|
|||||||
prepare bool
|
prepare bool
|
||||||
applyMode string
|
applyMode string
|
||||||
noReload bool
|
noReload bool
|
||||||
|
logLevel string
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -44,13 +46,28 @@ func init() {
|
|||||||
flag.BoolVar(&prepare, "prepare", false, "remove disabled monitors from config file and exit")
|
flag.BoolVar(&prepare, "prepare", false, "remove disabled monitors from config file and exit")
|
||||||
flag.StringVar(&applyMode, "apply", "", "apply a mode (portable/docked) and exit")
|
flag.StringVar(&applyMode, "apply", "", "apply a mode (portable/docked) and exit")
|
||||||
flag.BoolVar(&noReload, "no-reload", false, "when used with --apply, write config without reloading the compositor")
|
flag.BoolVar(&noReload, "no-reload", false, "when used with --apply, write config without reloading the compositor")
|
||||||
|
flag.StringVar(&logLevel, "log-level", "info", "log level: debug, info, warn, error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLogLevel converts a flag value to an slog.Level.
|
||||||
|
func parseLogLevel(s string) slog.Level {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "debug":
|
||||||
|
return slog.LevelDebug
|
||||||
|
case "warn", "warning":
|
||||||
|
return slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
return slog.LevelError
|
||||||
|
default:
|
||||||
|
return slog.LevelInfo
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
Level: slog.LevelInfo,
|
Level: parseLogLevel(logLevel),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ func (h *hyprlandBackend) WriteConfig(ctx context.Context, monitors []MonitorCon
|
|||||||
"error", err)
|
"error", err)
|
||||||
var plain []MonitorConfig
|
var plain []MonitorConfig
|
||||||
for _, m := range monitors {
|
for _, m := range monitors {
|
||||||
if !strings.HasPrefix(m.Name, "desc:") && !strings.HasPrefix(m.Name, "size:") {
|
if !strings.HasPrefix(m.Name, "desc:") && !strings.HasPrefix(m.Name, "serial:") && !strings.HasPrefix(m.Name, "size:") {
|
||||||
plain = append(plain, m)
|
plain = append(plain, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,19 +117,20 @@ type Backend interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveMonitorNames converts desc: or size: prefixed monitor config names
|
// resolveMonitorNames converts desc:, serial:, or size: prefixed monitor
|
||||||
// to the actual connector names by matching against the current set of
|
// config names to the actual connector names by matching against the current
|
||||||
// connected monitors.
|
// set of connected monitors.
|
||||||
//
|
//
|
||||||
// Matching logic:
|
// Matching logic:
|
||||||
// - "desc:text" — substring match against description or serial
|
// - "desc:text" — substring match against description or serial
|
||||||
|
// - "serial:text" — substring match against serial
|
||||||
// - "size:WxH" — match by exact pixel dimensions (e.g. "size:2560x1440")
|
// - "size:WxH" — match by exact pixel dimensions (e.g. "size:2560x1440")
|
||||||
// - "size:WxH@R" — match by dimensions + refresh rate (e.g. "size:2560x1440@165")
|
// - "size:WxH@R" — match by dimensions + refresh rate (e.g. "size:2560x1440@165")
|
||||||
// - plain names are returned as-is
|
// - plain names are returned as-is
|
||||||
func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonitors func(context.Context) ([]MonitorInfo, error), logger *slog.Logger) ([]MonitorConfig, error) {
|
func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonitors func(context.Context) ([]MonitorInfo, error), logger *slog.Logger) ([]MonitorConfig, error) {
|
||||||
var needsResolution bool
|
var needsResolution bool
|
||||||
for _, m := range monitors {
|
for _, m := range monitors {
|
||||||
if strings.HasPrefix(m.Name, "desc:") || strings.HasPrefix(m.Name, "size:") {
|
if strings.HasPrefix(m.Name, "desc:") || strings.HasPrefix(m.Name, "serial:") || strings.HasPrefix(m.Name, "size:") {
|
||||||
needsResolution = true
|
needsResolution = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -148,6 +149,8 @@ func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonit
|
|||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(m.Name, "desc:"):
|
case strings.HasPrefix(m.Name, "desc:"):
|
||||||
resolved[i] = resolveDesc(m, current, logger)
|
resolved[i] = resolveDesc(m, current, logger)
|
||||||
|
case strings.HasPrefix(m.Name, "serial:"):
|
||||||
|
resolved[i] = resolveSerial(m, current, logger)
|
||||||
case strings.HasPrefix(m.Name, "size:"):
|
case strings.HasPrefix(m.Name, "size:"):
|
||||||
resolved[i] = resolveSize(m, current, logger)
|
resolved[i] = resolveSize(m, current, logger)
|
||||||
default:
|
default:
|
||||||
@@ -161,6 +164,38 @@ func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonit
|
|||||||
return resolved, nil
|
return resolved, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveSerial resolves a serial: prefixed name to a connector name.
|
||||||
|
func resolveSerial(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig {
|
||||||
|
needle := strings.TrimPrefix(m.Name, "serial:")
|
||||||
|
var matches []MonitorInfo
|
||||||
|
for _, mi := range current {
|
||||||
|
if strings.Contains(mi.Serial, needle) {
|
||||||
|
matches = append(matches, mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch len(matches) {
|
||||||
|
case 0:
|
||||||
|
// Return as-is so the caller sees the empty name.
|
||||||
|
m.Name = ""
|
||||||
|
return m
|
||||||
|
case 1:
|
||||||
|
logger.Debug("resolved serial to connector",
|
||||||
|
"serial", needle, "connector", matches[0].Name)
|
||||||
|
m.Name = matches[0].Name
|
||||||
|
return m
|
||||||
|
default:
|
||||||
|
var names []string
|
||||||
|
for _, mat := range matches {
|
||||||
|
names = append(names, mat.Name)
|
||||||
|
}
|
||||||
|
logger.Warn("serial:%q matches multiple monitors: %s — use a more specific identifier",
|
||||||
|
needle, strings.Join(names, ", "))
|
||||||
|
m.Name = ""
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// resolveDesc resolves a desc: prefixed name to a connector name.
|
// resolveDesc resolves a desc: prefixed name to a connector name.
|
||||||
func resolveDesc(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig {
|
func resolveDesc(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig {
|
||||||
needle := strings.TrimPrefix(m.Name, "desc:")
|
needle := strings.TrimPrefix(m.Name, "desc:")
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(testDiscard{}, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
type testDiscard struct{}
|
||||||
|
|
||||||
|
func (testDiscard) Write(p []byte) (int, error) { return len(p), nil }
|
||||||
|
|
||||||
|
func TestResolveMonitorNamesSerial(t *testing.T) {
|
||||||
|
current := []MonitorInfo{
|
||||||
|
{Name: "eDP-1", Description: "Lenovo", Width: 1920, Height: 1200},
|
||||||
|
{Name: "DP-12", Description: "Xiaomi Mi Monitor", Serial: "3342300033911", Width: 2560, Height: 1440},
|
||||||
|
{Name: "DP-11", Description: "Xiaomi Mi Monitor", Serial: "", Width: 3440, Height: 1440},
|
||||||
|
}
|
||||||
|
getMonitors := func(context.Context) ([]MonitorInfo, error) { return current, nil }
|
||||||
|
|
||||||
|
monitors := []MonitorConfig{
|
||||||
|
{Name: "serial:3342300033911", Enabled: true},
|
||||||
|
{Name: "eDP-1", Enabled: false},
|
||||||
|
}
|
||||||
|
resolved, err := resolveMonitorNames(context.Background(), monitors, getMonitors, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveMonitorNames: %v", err)
|
||||||
|
}
|
||||||
|
if resolved[0].Name != "DP-12" {
|
||||||
|
t.Errorf("serial should resolve to DP-12, got %q", resolved[0].Name)
|
||||||
|
}
|
||||||
|
if resolved[1].Name != "eDP-1" {
|
||||||
|
t.Errorf("plain name should pass through, got %q", resolved[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMonitorNamesSerialNoMatch(t *testing.T) {
|
||||||
|
current := []MonitorInfo{
|
||||||
|
{Name: "DP-12", Description: "Xiaomi", Serial: "3342300033911"},
|
||||||
|
}
|
||||||
|
getMonitors := func(context.Context) ([]MonitorInfo, error) { return current, nil }
|
||||||
|
|
||||||
|
monitors := []MonitorConfig{{Name: "serial:9999999999", Enabled: true}}
|
||||||
|
if _, err := resolveMonitorNames(context.Background(), monitors, getMonitors, testLogger()); err == nil {
|
||||||
|
t.Error("unresolvable serial should return an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMonitorNamesSerialMultipleMatches(t *testing.T) {
|
||||||
|
current := []MonitorInfo{
|
||||||
|
{Name: "DP-1", Serial: "SN-XYZ"},
|
||||||
|
{Name: "DP-2", Serial: "SN-XYZ"},
|
||||||
|
}
|
||||||
|
getMonitors := func(context.Context) ([]MonitorInfo, error) { return current, nil }
|
||||||
|
|
||||||
|
monitors := []MonitorConfig{{Name: "serial:SN-XYZ", Enabled: true}}
|
||||||
|
if _, err := resolveMonitorNames(context.Background(), monitors, getMonitors, testLogger()); err == nil {
|
||||||
|
t.Error("ambiguous serial match should return an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMonitorNamesDescAlsoMatchesSerial(t *testing.T) {
|
||||||
|
current := []MonitorInfo{
|
||||||
|
{Name: "DP-12", Description: "Generic", Serial: "3342300033911"},
|
||||||
|
}
|
||||||
|
getMonitors := func(context.Context) ([]MonitorInfo, error) { return current, nil }
|
||||||
|
|
||||||
|
monitors := []MonitorConfig{{Name: "desc:3342300033911", Enabled: true}}
|
||||||
|
resolved, err := resolveMonitorNames(context.Background(), monitors, getMonitors, testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveMonitorNames: %v", err)
|
||||||
|
}
|
||||||
|
if resolved[0].Name != "DP-12" {
|
||||||
|
t.Errorf("desc should match via serial, got %q", resolved[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,9 +193,9 @@ func (s *swayBackend) GetMonitors(ctx context.Context) ([]MonitorInfo, error) {
|
|||||||
// then disabled monitors are turned off. All commands are sent in a single
|
// then disabled monitors are turned off. All commands are sent in a single
|
||||||
// swaymsg call separated by ';' so Sway executes them as one IPC message.
|
// swaymsg call separated by ';' so Sway executes them as one IPC message.
|
||||||
//
|
//
|
||||||
// Monitor names prefixed with "desc:" are resolved to actual connector names
|
// Monitor names prefixed with "desc:", "serial:", or "size:" are resolved
|
||||||
// by querying the current monitor state. This makes configs portable across
|
// to actual connector names by querying the current monitor state. This
|
||||||
// dock ports and reboots.
|
// makes configs portable across dock ports and reboots.
|
||||||
//
|
//
|
||||||
// Safety: the daemon guarantees at least one enabled monitor exists before
|
// Safety: the daemon guarantees at least one enabled monitor exists before
|
||||||
// calling ApplyLayout. Sway refuses to disable the last active output, so
|
// calling ApplyLayout. Sway refuses to disable the last active output, so
|
||||||
|
|||||||
+71
-10
@@ -5,7 +5,9 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -194,9 +196,14 @@ func countEnabled(entries []MonitorEntry) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isInternalConnector returns true if the connector name matches a known
|
// isInternalConnector returns true if the connector name matches a known
|
||||||
// internal display pattern (eDP, LVDS, DSI). These are always part of the
|
// internal display pattern (eDP, LVDS, DSI) or Hyprland's synthetic
|
||||||
// laptop or tablet and should never trigger docked mode.
|
// "FALLBACK" output. Internal and synthetic displays are always part of the
|
||||||
|
// laptop/tablet (or a no-display fallback) and should never trigger docked
|
||||||
|
// mode.
|
||||||
func isInternalConnector(name string) bool {
|
func isInternalConnector(name string) bool {
|
||||||
|
if strings.EqualFold(name, "fallback") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
prefixes := []string{"eDP-", "LVDS-", "DSI-", "EDP-"}
|
prefixes := []string{"eDP-", "LVDS-", "DSI-", "EDP-"}
|
||||||
for _, p := range prefixes {
|
for _, p := range prefixes {
|
||||||
if strings.HasPrefix(name, p) {
|
if strings.HasPrefix(name, p) {
|
||||||
@@ -206,31 +213,85 @@ func isInternalConnector(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExternalMonitor carries the identity of a detected monitor so the
|
||||||
|
// external list can match by name, description, serial, or resolution.
|
||||||
|
type ExternalMonitor struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Serial string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
RefreshRate float64
|
||||||
|
}
|
||||||
|
|
||||||
// MatchesExternal checks whether a monitor matches the External list.
|
// MatchesExternal checks whether a monitor matches the External list.
|
||||||
// Supports match modes:
|
// Supports match modes:
|
||||||
//
|
//
|
||||||
// - Plain name: exact match against name
|
// - Plain name: exact match against the connector name
|
||||||
// - desc: prefix: substring match against description
|
// - desc: prefix: substring match against description or serial
|
||||||
|
// - serial: prefix: substring match against serial
|
||||||
|
// - size:WxH / size:WxH@R: exact pixel dimensions, optionally plus
|
||||||
|
// refresh rate (within ±1 Hz)
|
||||||
//
|
//
|
||||||
// When the External list is empty, any monitor that is not an internal
|
// When the External list is empty, any monitor that is not an internal
|
||||||
// display connector (eDP-, LVDS-, DSI-) is automatically external.
|
// display connector (eDP-, LVDS-, DSI-) or synthetic output (FALLBACK)
|
||||||
func (c *Config) MatchesExternal(name, description string) bool {
|
// is automatically external.
|
||||||
|
func (c *Config) MatchesExternal(m ExternalMonitor) bool {
|
||||||
if len(c.External) == 0 {
|
if len(c.External) == 0 {
|
||||||
return !isInternalConnector(name)
|
return !isInternalConnector(m.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ext := range c.External {
|
for _, ext := range c.External {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(ext, "desc:"):
|
case strings.HasPrefix(ext, "desc:"):
|
||||||
desc := strings.TrimPrefix(ext, "desc:")
|
needle := strings.TrimPrefix(ext, "desc:")
|
||||||
if strings.Contains(description, desc) {
|
if strings.Contains(m.Description, needle) || strings.Contains(m.Serial, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(ext, "serial:"):
|
||||||
|
needle := strings.TrimPrefix(ext, "serial:")
|
||||||
|
if strings.Contains(m.Serial, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(ext, "size:"):
|
||||||
|
if matchSize(ext, m.Width, m.Height, m.RefreshRate) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if name == ext {
|
if m.Name == ext {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchSize reports whether a monitor matches a "size:WxH" or "size:WxH@R"
|
||||||
|
// spec by exact pixel dimensions and, when a refresh rate is given, by
|
||||||
|
// refresh rate within ±1 Hz.
|
||||||
|
func matchSize(spec string, width, height int, refresh float64) bool {
|
||||||
|
spec = strings.TrimPrefix(spec, "size:")
|
||||||
|
|
||||||
|
parts := strings.Split(spec, "@")
|
||||||
|
dimParts := strings.Split(parts[0], "x")
|
||||||
|
if len(dimParts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
w, errW := strconv.Atoi(strings.TrimSpace(dimParts[0]))
|
||||||
|
h, errH := strconv.Atoi(strings.TrimSpace(dimParts[1]))
|
||||||
|
if errW != nil || errH != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if w != width || h != height {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 2 {
|
||||||
|
r, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
|
||||||
|
if err != nil || math.Abs(r-refresh) > 1.0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMatchesExternalAutoDetect(t *testing.T) {
|
||||||
|
c := &Config{} // empty external list → auto-detect
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
m ExternalMonitor
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"internal eDP", ExternalMonitor{Name: "eDP-1"}, false},
|
||||||
|
{"internal LVDS", ExternalMonitor{Name: "LVDS-1"}, false},
|
||||||
|
{"internal DSI", ExternalMonitor{Name: "DSI-1"}, false},
|
||||||
|
{"synthetic fallback upper", ExternalMonitor{Name: "FALLBACK"}, false},
|
||||||
|
{"synthetic fallback lower", ExternalMonitor{Name: "fallback"}, false},
|
||||||
|
{"external DP", ExternalMonitor{Name: "DP-3"}, true},
|
||||||
|
{"external HDMI", ExternalMonitor{Name: "HDMI-A-1"}, true},
|
||||||
|
{"empty name", ExternalMonitor{Name: ""}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := c.MatchesExternal(tc.m); got != tc.want {
|
||||||
|
t.Errorf("MatchesExternal(%+v) = %v, want %v", tc.m, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesExternalPlainName(t *testing.T) {
|
||||||
|
c := &Config{External: []string{"DP-1", "DP-2"}}
|
||||||
|
|
||||||
|
if !c.MatchesExternal(ExternalMonitor{Name: "DP-1"}) {
|
||||||
|
t.Error("exact DP-1 should match")
|
||||||
|
}
|
||||||
|
if c.MatchesExternal(ExternalMonitor{Name: "DP-3"}) {
|
||||||
|
t.Error("DP-3 should not match when external lists DP-1/DP-2")
|
||||||
|
}
|
||||||
|
if c.MatchesExternal(ExternalMonitor{Name: "eDP-1"}) {
|
||||||
|
t.Error("eDP-1 should not match explicit external list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesExternalDescMatchesDescriptionAndSerial(t *testing.T) {
|
||||||
|
c := &Config{External: []string{"desc:Xiaomi"}}
|
||||||
|
|
||||||
|
m := ExternalMonitor{
|
||||||
|
Name: "DP-12",
|
||||||
|
Description: "Xiaomi Corporation Mi Monitor 3342300033911",
|
||||||
|
Serial: "3342300033911",
|
||||||
|
}
|
||||||
|
if !c.MatchesExternal(m) {
|
||||||
|
t.Error("desc should match via description")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Serial = ""
|
||||||
|
m.Description = "Generic Monitor"
|
||||||
|
if c.MatchesExternal(m) {
|
||||||
|
t.Error("desc should not match when neither description nor serial contains needle")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Description = "Generic Monitor"
|
||||||
|
m.Serial = "Xiaomi 3342300033911"
|
||||||
|
if !c.MatchesExternal(m) {
|
||||||
|
t.Error("desc should match via serial even when description lacks the needle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesExternalSerial(t *testing.T) {
|
||||||
|
c := &Config{External: []string{"serial:3342300033911"}}
|
||||||
|
|
||||||
|
if !c.MatchesExternal(ExternalMonitor{Name: "DP-12", Serial: "3342300033911"}) {
|
||||||
|
t.Error("serial exact should match")
|
||||||
|
}
|
||||||
|
if !c.MatchesExternal(ExternalMonitor{Name: "DP-12", Serial: "WXYZ3342300033911ABC"}) {
|
||||||
|
t.Error("serial substring should match")
|
||||||
|
}
|
||||||
|
if c.MatchesExternal(ExternalMonitor{Name: "DP-12", Serial: ""}) {
|
||||||
|
t.Error("empty serial should not match serial prefix")
|
||||||
|
}
|
||||||
|
if c.MatchesExternal(ExternalMonitor{Name: "DP-12", Serial: "3342300033912"}) {
|
||||||
|
t.Error("different serial should not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesExternalSize(t *testing.T) {
|
||||||
|
c := &Config{External: []string{"size:2560x1440"}}
|
||||||
|
|
||||||
|
if !c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 2560, Height: 1440}) {
|
||||||
|
t.Error("size exact should match")
|
||||||
|
}
|
||||||
|
if c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 1920, Height: 1200}) {
|
||||||
|
t.Error("different size should not match")
|
||||||
|
}
|
||||||
|
if c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 2560, Height: 1080}) {
|
||||||
|
t.Error("mixed dimensions should not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesExternalSizeWithRefresh(t *testing.T) {
|
||||||
|
c := &Config{External: []string{"size:2560x1440@165"}}
|
||||||
|
|
||||||
|
if !c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 2560, Height: 1440, RefreshRate: 165}) {
|
||||||
|
t.Error("size+refresh exact should match")
|
||||||
|
}
|
||||||
|
if !c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 2560, Height: 1440, RefreshRate: 164.999}) {
|
||||||
|
t.Error("size+refresh within tolerance should match")
|
||||||
|
}
|
||||||
|
if c.MatchesExternal(ExternalMonitor{Name: "DP-12", Width: 2560, Height: 1440, RefreshRate: 120}) {
|
||||||
|
t.Error("different refresh should not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchesExternalPrefersAnyMatchingEntry(t *testing.T) {
|
||||||
|
c := &Config{External: []string{"DP-1", "serial:3342300033911"}}
|
||||||
|
|
||||||
|
if !c.MatchesExternal(ExternalMonitor{Name: "DP-1"}) {
|
||||||
|
t.Error("plain name entry should match")
|
||||||
|
}
|
||||||
|
if !c.MatchesExternal(ExternalMonitor{Name: "DP-9", Serial: "3342300033911"}) {
|
||||||
|
t.Error("serial entry should match via second list item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSizeInvalid(t *testing.T) {
|
||||||
|
if matchSize("size:not-a-size", 2560, 1440, 0) {
|
||||||
|
t.Error("garbage size spec should not match")
|
||||||
|
}
|
||||||
|
if matchSize("size:2560", 2560, 0, 0) {
|
||||||
|
t.Error("malformed dimensions should not match")
|
||||||
|
}
|
||||||
|
if matchSize("size:2560x1440@bogus", 2560, 1440, 165) {
|
||||||
|
t.Error("malformed refresh rate should not match")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,6 +183,9 @@ func (d *Daemon) pollCheck(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// determineState checks whether any external monitor is physically connected.
|
// determineState checks whether any external monitor is physically connected.
|
||||||
|
// Synthetic outputs that never represent a real external display (e.g.
|
||||||
|
// Hyprland's FALLBACK monitor) are excluded by MatchesExternal, so a stuck
|
||||||
|
// docked config cannot keep the daemon in docked mode after undocking.
|
||||||
func (d *Daemon) determineState(monitors []backend.MonitorInfo) backend.State {
|
func (d *Daemon) determineState(monitors []backend.MonitorInfo) backend.State {
|
||||||
for _, m := range monitors {
|
for _, m := range monitors {
|
||||||
// Skip phantom or disconnected monitors.
|
// Skip phantom or disconnected monitors.
|
||||||
@@ -191,7 +194,14 @@ func (d *Daemon) determineState(monitors []backend.MonitorInfo) backend.State {
|
|||||||
}
|
}
|
||||||
// A monitor is physically present if it reports non-zero dimensions.
|
// A monitor is physically present if it reports non-zero dimensions.
|
||||||
if m.Width > 0 && m.Height > 0 {
|
if m.Width > 0 && m.Height > 0 {
|
||||||
if d.config.MatchesExternal(m.Name, m.Description) {
|
if d.config.MatchesExternal(config.ExternalMonitor{
|
||||||
|
Name: m.Name,
|
||||||
|
Description: m.Description,
|
||||||
|
Serial: m.Serial,
|
||||||
|
Width: m.Width,
|
||||||
|
Height: m.Height,
|
||||||
|
RefreshRate: m.RefreshRate,
|
||||||
|
}) {
|
||||||
return backend.StateDocked
|
return backend.StateDocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,11 +271,19 @@ func (d *Daemon) applyState(ctx context.Context, state backend.State) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// anyExternalConnected returns true if at least one configured external
|
// anyExternalConnected returns true if at least one configured external
|
||||||
// monitor is physically present.
|
// monitor is physically present. Synthetic outputs (e.g. Hyprland's FALLBACK)
|
||||||
|
// are excluded via MatchesExternal.
|
||||||
func (d *Daemon) anyExternalConnected(monitors []backend.MonitorInfo) bool {
|
func (d *Daemon) anyExternalConnected(monitors []backend.MonitorInfo) bool {
|
||||||
for _, m := range monitors {
|
for _, m := range monitors {
|
||||||
if m.Width > 0 && m.Height > 0 && m.Name != "" {
|
if m.Width > 0 && m.Height > 0 && m.Name != "" {
|
||||||
if d.config.MatchesExternal(m.Name, m.Description) {
|
if d.config.MatchesExternal(config.ExternalMonitor{
|
||||||
|
Name: m.Name,
|
||||||
|
Description: m.Description,
|
||||||
|
Serial: m.Serial,
|
||||||
|
Width: m.Width,
|
||||||
|
Height: m.Height,
|
||||||
|
RefreshRate: m.RefreshRate,
|
||||||
|
}) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ restore_on_exit: true
|
|||||||
|
|
||||||
# External monitors that trigger docked mode.
|
# External monitors that trigger docked mode.
|
||||||
# Plain name: matches the connector name (e.g. DP-1, HDMI-A-1).
|
# Plain name: matches the connector name (e.g. DP-1, HDMI-A-1).
|
||||||
# desc: prefix: matches by monitor description (survives rename).
|
# desc: prefix: matches by monitor description or serial (survives rename).
|
||||||
|
# serial: prefix: matches by serial number (e.g. serial:3342300033911).
|
||||||
|
# size: prefix: matches by resolution, optionally with refresh
|
||||||
|
# (e.g. size:2560x1440 or size:2560x1440@165).
|
||||||
# Optional — if omitted or left empty, the daemon auto-detects external
|
# Optional — if omitted or left empty, the daemon auto-detects external
|
||||||
# monitors: any display whose connector is not eDP-/LVDS-/DSI- is treated
|
# monitors: any display whose connector is not eDP-/LVDS-/DSI- is treated
|
||||||
# as external.
|
# as external.
|
||||||
@@ -33,13 +36,16 @@ external:
|
|||||||
- DP-1
|
- DP-1
|
||||||
- DP-2
|
- DP-2
|
||||||
- desc:Dell Inc. DELL U2723QE
|
- desc:Dell Inc. DELL U2723QE
|
||||||
|
- serial:3342300033911
|
||||||
|
- size:3440x1440@120
|
||||||
|
|
||||||
# Monitor layouts for each mode.
|
# Monitor layouts for each mode.
|
||||||
# "portable" and "docked" are required.
|
# "portable" and "docked" are required.
|
||||||
# Monitors connected but not listed in the mode are automatically disabled.
|
# Monitors connected but not listed in the mode are automatically disabled.
|
||||||
#
|
#
|
||||||
# Fields:
|
# Fields:
|
||||||
# name — connector name, desc:description, size:WxH, or size:WxH@R
|
# name — connector name, desc:description, serial:XXXX, size:WxH,
|
||||||
|
# or size:WxH@R
|
||||||
# enabled — true to show, false to disable
|
# enabled — true to show, false to disable
|
||||||
# mode — "preferred" (auto-detect), "1920x1080@60", etc.
|
# mode — "preferred" (auto-detect), "1920x1080@60", etc.
|
||||||
# position — "auto", "0x0", "1920x0", etc.
|
# position — "auto", "0x0", "1920x0", etc.
|
||||||
|
|||||||
Reference in New Issue
Block a user