From ab6cad0505794eed429e58bd12dc5051a2be2100 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Thu, 6 Aug 2026 12:25:47 +0700 Subject: [PATCH] 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. --- README.md | 24 +++-- cmd/monitor-lets-go/main.go | 19 +++- internal/backend/hyprland.go | 2 +- internal/backend/interface.go | 43 ++++++++- internal/backend/interface_test.go | 80 +++++++++++++++++ internal/backend/sway.go | 6 +- internal/config/config.go | 81 ++++++++++++++--- internal/config/config_test.go | 135 +++++++++++++++++++++++++++++ internal/daemon/daemon.go | 24 ++++- monitor-lets-go.example.yaml | 10 ++- 10 files changed, 393 insertions(+), 31 deletions(-) create mode 100644 internal/backend/interface_test.go create mode 100644 internal/config/config_test.go diff --git a/README.md b/README.md index 9967562..7703b24 100644 --- a/README.md +++ b/README.md @@ -144,11 +144,15 @@ restore_on_exit: true # External monitors that trigger docked mode. # 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: # any display whose connector is not eDP-/LVDS-/DSI- is treated as external. external: # optional - desc:Dell Inc. DELL U2723QE + - serial:3342300033911 + - size:3440x1440 - DP-9 - DP-10 @@ -197,19 +201,22 @@ hooks: ### 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 | |---|---|---| | `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 | -| `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 | 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 @@ -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 -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. ```yaml external: - desc:Dell Inc. DELL U2723QE # survives port rename + - serial:3342300033911 # match a specific monitor by serial modes: docked: monitors: - name: desc:Dell Inc. DELL U2723QE # also works here enabled: true + - name: serial:3342300033911 # and here + enabled: true - name: size:2560x1440@165 # match by dimensions + refresh enabled: true ``` diff --git a/cmd/monitor-lets-go/main.go b/cmd/monitor-lets-go/main.go index 432257b..3974f4f 100644 --- a/cmd/monitor-lets-go/main.go +++ b/cmd/monitor-lets-go/main.go @@ -23,6 +23,7 @@ import ( "os" "os/signal" "path/filepath" + "strings" "syscall" "time" @@ -37,6 +38,7 @@ var ( prepare bool applyMode string noReload bool + logLevel string ) func init() { @@ -44,13 +46,28 @@ func init() { 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.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() { flag.Parse() logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelInfo, + Level: parseLogLevel(logLevel), })) switch { diff --git a/internal/backend/hyprland.go b/internal/backend/hyprland.go index 0c2cda7..c05e117 100644 --- a/internal/backend/hyprland.go +++ b/internal/backend/hyprland.go @@ -147,7 +147,7 @@ func (h *hyprlandBackend) WriteConfig(ctx context.Context, monitors []MonitorCon "error", err) var plain []MonitorConfig 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) } } diff --git a/internal/backend/interface.go b/internal/backend/interface.go index 2b2fff1..16edfd0 100644 --- a/internal/backend/interface.go +++ b/internal/backend/interface.go @@ -117,19 +117,20 @@ type Backend interface { Close() error } -// resolveMonitorNames converts desc: or size: prefixed monitor config names -// to the actual connector names by matching against the current set of -// connected monitors. +// resolveMonitorNames converts desc:, serial:, or size: prefixed monitor +// config names to the actual connector names by matching against the current +// set of connected monitors. // // Matching logic: // - "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@R" — match by dimensions + refresh rate (e.g. "size:2560x1440@165") // - plain names are returned as-is func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonitors func(context.Context) ([]MonitorInfo, error), logger *slog.Logger) ([]MonitorConfig, error) { var needsResolution bool 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 break } @@ -148,6 +149,8 @@ func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonit switch { case strings.HasPrefix(m.Name, "desc:"): resolved[i] = resolveDesc(m, current, logger) + case strings.HasPrefix(m.Name, "serial:"): + resolved[i] = resolveSerial(m, current, logger) case strings.HasPrefix(m.Name, "size:"): resolved[i] = resolveSize(m, current, logger) default: @@ -161,6 +164,38 @@ func resolveMonitorNames(ctx context.Context, monitors []MonitorConfig, getMonit 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. func resolveDesc(m MonitorConfig, current []MonitorInfo, logger *slog.Logger) MonitorConfig { needle := strings.TrimPrefix(m.Name, "desc:") diff --git a/internal/backend/interface_test.go b/internal/backend/interface_test.go new file mode 100644 index 0000000..1445e1b --- /dev/null +++ b/internal/backend/interface_test.go @@ -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) + } +} diff --git a/internal/backend/sway.go b/internal/backend/sway.go index 9940e0b..ca6dd6b 100644 --- a/internal/backend/sway.go +++ b/internal/backend/sway.go @@ -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 // swaymsg call separated by ';' so Sway executes them as one IPC message. // -// Monitor names prefixed with "desc:" are resolved to actual connector names -// by querying the current monitor state. This makes configs portable across -// dock ports and reboots. +// Monitor names prefixed with "desc:", "serial:", or "size:" are resolved +// to actual connector names by querying the current monitor state. This +// makes configs portable across dock ports and reboots. // // Safety: the daemon guarantees at least one enabled monitor exists before // calling ApplyLayout. Sway refuses to disable the last active output, so diff --git a/internal/config/config.go b/internal/config/config.go index c2a8b16..5483d04 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,7 +5,9 @@ package config import ( "errors" "fmt" + "math" "os" + "strconv" "strings" "time" @@ -194,9 +196,14 @@ func countEnabled(entries []MonitorEntry) int { } // isInternalConnector returns true if the connector name matches a known -// internal display pattern (eDP, LVDS, DSI). These are always part of the -// laptop or tablet and should never trigger docked mode. +// internal display pattern (eDP, LVDS, DSI) or Hyprland's synthetic +// "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 { + if strings.EqualFold(name, "fallback") { + return true + } prefixes := []string{"eDP-", "LVDS-", "DSI-", "EDP-"} for _, p := range prefixes { if strings.HasPrefix(name, p) { @@ -206,31 +213,85 @@ func isInternalConnector(name string) bool { 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. // Supports match modes: // -// - Plain name: exact match against name -// - desc: prefix: substring match against description +// - Plain name: exact match against the connector name +// - 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 -// display connector (eDP-, LVDS-, DSI-) is automatically external. -func (c *Config) MatchesExternal(name, description string) bool { +// display connector (eDP-, LVDS-, DSI-) or synthetic output (FALLBACK) +// is automatically external. +func (c *Config) MatchesExternal(m ExternalMonitor) bool { if len(c.External) == 0 { - return !isInternalConnector(name) + return !isInternalConnector(m.Name) } for _, ext := range c.External { switch { case strings.HasPrefix(ext, "desc:"): - desc := strings.TrimPrefix(ext, "desc:") - if strings.Contains(description, desc) { + needle := strings.TrimPrefix(ext, "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 } default: - if name == ext { + if m.Name == ext { return true } } } 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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4c9d6dd --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 5686078..2005ed8 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -183,6 +183,9 @@ func (d *Daemon) pollCheck(ctx context.Context) { } // 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 { for _, m := range 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. 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 } } @@ -261,11 +271,19 @@ func (d *Daemon) applyState(ctx context.Context, state backend.State) error { } // 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 { for _, m := range monitors { 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 } } diff --git a/monitor-lets-go.example.yaml b/monitor-lets-go.example.yaml index e559ea2..4338931 100644 --- a/monitor-lets-go.example.yaml +++ b/monitor-lets-go.example.yaml @@ -25,7 +25,10 @@ restore_on_exit: true # External monitors that trigger docked mode. # 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 # monitors: any display whose connector is not eDP-/LVDS-/DSI- is treated # as external. @@ -33,13 +36,16 @@ external: - DP-1 - DP-2 - desc:Dell Inc. DELL U2723QE + - serial:3342300033911 + - size:3440x1440@120 # Monitor layouts for each mode. # "portable" and "docked" are required. # Monitors connected but not listed in the mode are automatically disabled. # # 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 # mode — "preferred" (auto-detect), "1920x1080@60", etc. # position — "auto", "0x0", "1920x0", etc.