Wildcard or list matching for source/sink #3

Closed
opened 2026-11-12 19:43:28 +01:00 by lucas · 0 comments
Owner

Problem

Allow matching nodes by wildcard/regex.
Also allow a list wherever a node can be specified. Each list entry can be a wildcard, direct match or friendly name.

Today every node reference is one exact node.name string. ActionConfig::params is
std::unordered_map<std::string, std::string>, so a JSON array cannot even be parsed;
IActionBase::m_Node, Action_Connection::m_Left/m_Right each hold one name; and
AudioService is keyed by exact name throughout (Nodes, NodeListeners by name,
ConnectionListeners by the literal string left + "->" + right).

Resolved semantics for this feature:

  • A node entry is a regex (std::regex, ECMAScript grammar, case-sensitive), implicitly
    anchored by compiling "^(?:" + entry + ")$". The non-capturing group is required:
    ^a|b$ would anchor only each alternative's outer edge.
  • Anywhere a node may be named (target, left, right, and FriendlyNames values), the JSON
    value may be a string or an array of strings. Every entry is independently a regex, a literal
    name (a literal is just a regex that matches itself) or a FriendlyNames alias.
  • An alias is resolved before matching, by exact whole-string lookup, and may expand to one entry
    or to a list that is spliced into the surrounding list. Expansion is single-level: entries
    produced by an alias are not themselves re-resolved, so no cycle detection is needed.
  • Writes fan out, reads take the representative. A MIDI press applies to every matched node
    (every left×right pair for connection), but the state an action reads back — and therefore
    what its LED shows — comes from a single representative node: the first configured entry
    that currently has a live match, and within that entry the match with the lowest PipeWire bound
    id (the oldest surviving node). A user who wants a specific node to drive the LED puts a
    sufficiently specific entry first. With no live match there is no representative: the action
    reads as not-muted / not-connected, the LED is off and a press is a no-op.
    Consequence, by design: when the representative is muted externally the LED lights while other
    matches stay unmuted, and the next press (desired = negation of what the LED shows) unmutes
    everything.
  • State is sticky: once an action has commanded a state, a node that starts matching later is
    pushed to that state (mute after the first press, volume after the first MIDI move,
    connection from startup when default-state is present). For connection this replaces
    today's "re-enforce default-state whenever a node reappears", which clobbers manual toggles.
  • connection manages the full cross product of left and right matches.
  • The node cache is restricted to nodes whose media.class contains Audio, so a broad pattern
    cannot reach cameras, screen-share or the nanoKONTROL2's own MIDI node.

Plan

1. NodeMatcher: the pattern primitive

New includes/NodeMatcher.h + src/NodeMatcher.cpp. It must include only <cstddef>,
<optional>, <regex>, <string>, <vector> (plus <iostream> in the .cpp) — no
glib/WirePlumber — so the test target can link it without PipeWire. No equivalent exists in the
repo.

Entries stay individually addressable: the representative rule needs to know which entry
matched, so the matcher must not collapse its entries into a single any_of predicate.

// Matches PipeWire node names against the regex entries of one configured node
// specification. Entries are already FriendlyNames-expanded; each is anchored
// so a plain name matches only itself. Entry order is the configured order and
// is significant: it ranks matches for the caller.
class NodeMatcher {
public:
    NodeMatcher() = default;
    explicit NodeMatcher(const std::vector<std::string>& patterns);

    // Index of the first entry matching nodeName, or nullopt when none does.
    [[nodiscard]] std::optional<std::size_t> FirstMatchingEntry(const std::string& nodeName) const;
    [[nodiscard]] bool Matches(const std::string& nodeName) const;
    [[nodiscard]] bool IsEmpty() const;
    [[nodiscard]] const std::vector<std::string>& Patterns() const;

private:
    std::vector<std::regex> m_Regexes;
    std::vector<std::string> m_Patterns;
};
  • Constructor: for each entry compile std::regex("^(?:" + entry + ")$", std::regex::ECMAScript).
    Catch const std::regex_error& e, write to std::cerr
    Warning: invalid node pattern "<entry>": <e.what()> – entry ignored. and skip that entry. A
    bad entry must never abort startup, and must not disable the other entries of the same list.
    Compilation happens once here; node events never recompile. A skipped entry is dropped from
    both vectors, so the remaining entries keep their relative order (indices shift, which is
    harmless — only relative order is used).
  • FirstMatchingEntry: linear scan in order, returning the first index whose
    std::regex_match succeeds.
  • Matches: FirstMatchingEntry(nodeName).has_value().
  • IsEmpty: m_Regexes.empty() — no usable entry; actions use it to decide they are inactive.
    m_Patterns keeps the source text for log messages.

2. Config schema: string-or-list params

includes/AppSettings.h — normalise every parameter to a list, so there is one type everywhere
and scalars are simply one-element lists:

using ActionParams = std::unordered_map<std::string, std::vector<std::string>>;

struct ActionConfig {
    std::string type;
    ActionParams params;
};

struct AppSettings {
    std::unordered_map<std::string, std::vector<std::string>> FriendlyNames;
    std::unordered_map<std::string, ActionConfig> Actions;
};

// Single value of a scalar parameter, or nullptr when absent or empty.
const std::string* GetScalarParam(const ActionParams& params, const std::string& key);

// Replaces every entry that is exactly a FriendlyNames key with that alias's
// entries, splicing lists in place. Alias values are not themselves resolved.
void ExpandFriendlyNames(AppSettings& settings);

GetScalarParam returns &list.front(); when the list holds more than one entry it first warns
on std::cerr (Warning: parameter "<key>" expects a single value, using the first of N.).
Keep AppSettings.h free of glaze includes — the header is pulled in by the action headers.

src/AppSettings.cpp — replace the direct glz::read_file_json(Settings, …) reflection with a
glz::json_t DOM walk, because the parsed shape is now polymorphic (string or array) per key:

  • glz::json_t Root; const auto Error = glz::read_file_json(Root, configFile, Buffer); — keep the
    existing failure message and the "Config loaded from …" success message verbatim.
  • Static helper
    bool ToStringList(const glz::json_t& value, std::vector<std::string>& out, const std::string& context):
    a string yields one entry; an array yields one entry per element, rejecting non-string elements
    with Warning: <context>: list entries must be strings – entry ignored.; anything else warns
    Warning: <context>: expected a string or a list of strings – ignored. and returns false.
  • Read key FriendlyNames (object) through ToStringList per value.
  • Read the actions object under key actions, falling back to Actions when actions is absent.
    Both spellings are accepted because the README and config-example.json document lowercase
    actions while the previous struct field was Actions; which one the installed glaze
    reflection actually accepted cannot be checked here, so neither existing config is broken.
  • Per action: type (string, required — warn and skip the action when missing), params
    (object, optional) with each value through ToStringList.
  • Call ExpandFriendlyNames(Settings) before returning, so actions never see aliases.
  • glz::json_t accessor names (is_object(), is_array(), is_string(), get_object(),
    get_array(), get<std::string>()) are unverified — confirm first against the installed
    glaze headers; glaze is not installed in the planning environment. They are a mechanical API
    lookup, not a design choice.

ExpandFriendlyNames walks every ActionConfig::params value list and builds a replacement list:
an entry that is exactly a key of FriendlyNames contributes that alias's entries in order; any
other entry contributes itself. Alias output is appended as-is (no re-lookup). This replaces the
loop in src/main.cpp:140-144, which substituted whole param values only; delete that loop.

3. Action API carries lists

Thread ActionParams through in place of std::unordered_map<std::string, std::string> —
ActionFactoryRegistry::BuildAction and its FactoryFunction typedef, the lambda inside
IAction::Registrator, IActionBase::Init, and the Init_Internal override in all three
actions. includes/Actions/ActionFactoryRegistry.h and includes/Actions/IAction.h include
AppSettings.h for the alias. No other callers exist (main.cpp passes
action.second.params straight through).

In IActionBase (includes/Actions/IAction.h / src/Actions/IAction.cpp), replace
std::string m_Node with NodeMatcher m_Target, built in Init from params entry target
(absent → default-constructed, i.e. empty). Add two protected conveniences used by every action:

// Matched node names in representative order: by configured entry, then by
// ascending PipeWire bound id. Empty when nothing matches.
[[nodiscard]] std::vector<std::string> MatchedNodes() const;

// The representative node name, or nullopt when nothing matches.
[[nodiscard]] std::optional<std::string> RepresentativeNode() const;

MatchedNodes forwards to m_audioService->GetMatchingNodes(m_Target); RepresentativeNode
returns front() of that list, or std::nullopt when it is empty.

4. AudioService: pattern-based cache, listeners and ranked queries

includes/AudioService.h includes NodeMatcher.h. Replace both listener maps with subscription
vectors, since a listener is no longer identified by a name key:

struct NodeSubscription { NodeMatcher Matcher; IActionBase* Listener; };
struct LinkSubscription { NodeMatcher Left; NodeMatcher Right; IActionBase* Listener; };

std::vector<NodeSubscription> NodeListeners;
std::vector<LinkSubscription> ConnectionListeners;
  • RegisterNodeListener(const NodeMatcher& matcher, IActionBase* listener) and
    RegisterLinkListener(const NodeMatcher& left, const NodeMatcher& right, IActionBase* listener)
    replace the name-keyed overloads. Keep the existing duplicate-registration guard (skip when the
    same listener pointer is already subscribed).

  • UnregisterNodeListener / UnregisterLinkListener keep their signatures but are rewritten as
    std::erase_if(NodeListeners, [listener](const NodeSubscription& s) { return s.Listener == listener; });
    The current bodies loop for (auto Itr = val.rbegin(); Itr != val.rbegin(); ++Itr) — condition
    rbegin() != rbegin() — so they never execute; the replacement actually unsubscribes.

  • New ranked query, the single place the representative order is defined:

    // Cached node names matching the matcher, ordered by configured entry index
    // then by ascending PipeWire bound id. A node matched by several entries
    // appears once, at its first matching entry. front() is the representative.
    std::vector<std::string> GetMatchingNodes(const NodeMatcher& matcher) const;
    

    Implementation: for each [name, node] in Nodes, take
    matcher.FirstMatchingEntry(name); when set, collect
    {entryIndex, wp_proxy_get_bound_id(WP_PROXY(node.get())), name}. Sort by
    (entryIndex, boundId) and project out the names. The two sort keys make the result
    independent of Nodes' unordered_map iteration order, which changes on rehash — i.e. an
    unrelated node appearing must never reorder an action's matches. Collecting by first matching
    entry inherently deduplicates.

  • DoesNodeExist loses its only caller (Action_Connection::on_node_available); delete it
    together with that call.

  • GetMute / SetMute / SetVolume / GetConnectionState / SetConnectionState keep their
    per-name signatures unchanged: matching is resolved by the action, mutation stays per node.

  • on_node_added (after reading node.name): read
    wp_pipewire_object_get_property(WP_PIPEWIRE_OBJECT(Node), "media.class"); when it is null or
    does not contain the substring "Audio", return before caching. This filters in C++ rather than
    via a wp_object_manager_add_interest constraint so no unverifiable WirePlumber constraint
    syntax is involved; wp_pipewire_object_get_property is already used on the adjacent line.
    on_node_removed needs no filter — erasing an absent name is a no-op, and a non-cached node
    reaches no listener since dispatch is now driven by the subscriptions.

  • on_node_added, on_node_removed, on_node_params_changed, on_node_ports_changed: replace
    the NodeListeners.find(Name) lookup with a loop over NodeListeners calling the listener when
    Subscription.Matcher.Matches(Name). Dispatch stays "any match": an action must be notified
    about every node it controls, not only its representative, because a non-representative node
    appearing still needs sticky state pushed onto it and can change which node is representative.

  • on_link_added / on_link_removed: keep the existing id→name resolution loop producing
    LeftNodeName / RightNodeName, then replace the ConnectionListeners.find(left + "->" + right)
    lookup with a loop over ConnectionListeners notifying when
    Sub.Left.Matches(LeftNodeName) && Sub.Right.Matches(RightNodeName).

5. Action_Mute: representative read, fan-out write, sticky after first press

State: keep m_bIsMuted (now the representative's state) and m_bWaitingForLow; add
bool m_bDesiredMute{false} and bool m_bHasDesired{false}.

  • Init_Internal: when m_Target.IsEmpty(), keep the existing warning and return without
    registering. Otherwise m_audioService->RegisterNodeListener(m_Target, this).
  • Private void RefreshState():
    const auto Representative = RepresentativeNode();
    const bool bNew = Representative.has_value() && m_audioService->GetMute(*Representative);
    
    Update the LED (guarded by LedManager::IsLedCapable) only when the value changed, as today.
    One GetMute call per event regardless of how many nodes match.
  • on_midi(127) (first high, m_bWaitingForLow false): fetch MatchedNodes(); when empty, set
    m_bWaitingForLow = true and return without touching state or LED (no-op press). Otherwise
    m_bDesiredMute = !m_bIsMuted; m_bHasDesired = true; call SetMute(node, m_bDesiredMute) for
    every matched node, set m_bIsMuted = m_bDesiredMute and update the LED. value == 0
    clears m_bWaitingForLow unchanged.
  • on_node_available: when m_bHasDesired, m_audioService->SetMute(name, m_bDesiredMute) for
    the node that just appeared, then RefreshState() (the new node may be the representative).
    Before the first press no state is pushed.
  • on_node_params_changed and a new on_node_removed override: RefreshState(). The removal
    override is needed because losing the representative promotes the next-ranked node.
  • The name == m_Node guards in the existing handlers disappear: AudioService only dispatches
    to matching subscriptions now, and a params change on any matched node must re-read the
    representative (its own change may be what the LED should show).

6. Action_Volume: applies to all matches, sticky after first move

Add float m_LastVolume{0.0f} and bool m_bHasVolume{false}. Volume has no read-back state, so
the representative plays no part here.

  • Init_Internal: keep the existing pow parsing but source the value via
    GetScalarParam(params, "pow"). Then, when !m_Target.IsEmpty(),
    m_audioService->RegisterNodeListener(m_Target, this) — the action registers no listener today.
    When the matcher is empty, warn
    Warning: Volume action has no "target" node specified – action will be inactive. (mirrors the
    mute warning, which has no volume counterpart today) and return.
  • on_midi: m_LastVolume = ::ConvertVolume(value, m_PowOverride); m_bHasVolume = true; then
    SetVolume(name, m_LastVolume) for every name in MatchedNodes().
  • New on_node_available override: when m_bHasVolume, SetVolume(name, m_LastVolume) for the
    node that appeared.

7. Action_Connection: cross-product writes, representative-pair read

Replace std::string m_Left/m_Right with NodeMatcher m_Left/m_Right; keep m_bIsConnected
(the representative pair's state) and m_bWaitingForLow; replace m_bDefaultState with
bool m_bDesiredState{false} plus bool m_bHasDesired{false}.

  • Init_Internal: build the matchers from params entries left and right, keeping the existing
    missing-parameter warnings (also warn when a present entry list compiles to an empty matcher).
    When both matchers are non-empty: RegisterNodeListener(m_Left, this),
    RegisterNodeListener(m_Right, this), RegisterLinkListener(m_Left, m_Right, this) — once; the
    current code calls RegisterLinkListener twice. default-state via
    GetScalarParam(params, "default-state"): when present set
    m_bDesiredState = (*value == "connected"); m_bHasDesired = true; else keep the existing
    "default state enforcement disabled" warning and leave m_bHasDesired false.
  • Private std::vector<std::pair<std::string, std::string>> Pairs() const: cross product of
    GetMatchingNodes(m_Left) × GetMatchingNodes(m_Right) in that ranked order (left outer, right
    inner), skipping any pair whose two names are equal (a node cannot be linked to itself; both
    matchers can match the same node when the patterns overlap). Pairs().front() is therefore the
    representative pair: first-ranked left with first-ranked right.
  • Private void RefreshState(): const auto P = Pairs();
    m_bIsConnected = !P.empty() && m_audioService->GetConnectionState(P.front().first, P.front().second);
    then Refresh_LED_State() (kept as-is). One GetConnectionState call per event.
  • Private void EnforceDesired(): for each pair whose GetConnectionState differs from
    m_bDesiredState, call SetConnectionState(left, right, m_bDesiredState). Idempotent, so
    calling it per event is safe.
  • on_midi(127) (first high): const auto P = Pairs(); when empty, set m_bWaitingForLow = true
    and return (no-op press). Otherwise m_bDesiredState = !m_bIsConnected; m_bHasDesired = true;
    SetConnectionState(l, r, m_bDesiredState) for every pair, m_bIsConnected = m_bDesiredState,
    Refresh_LED_State().
  • on_node_available and on_node_ports_changed (keep the portsNum == 0 early return): when
    m_bHasDesired call EnforceDesired(), then RefreshState(). The DoesNodeExist check at the
    top of on_node_available goes away — Pairs() is empty when either side has no live match,
    which RefreshState already handles.
  • on_link_created / on_link_removed: RefreshState() only — never EnforceDesired(), so a
    link a user creates by hand is reflected in the LED instead of being torn down immediately. Drop
    the leftNodeName == m_Left guards; AudioService only dispatches matching pairs.
  • Add an on_node_removed override calling RefreshState(), so the read follows a changed
    representative pair.

8. main.cpp

Delete the FriendlyNames substitution loop (src/main.cpp:140-144) — now done in
ExpandFriendlyNames during config load. Everything else in the action-build loop is unchanged;
action.second.params simply has the new type.

9. Unit-test target

tests/NodeMatcher_tests.cpp, dependency-free (no Catch2/doctest): a CHECK(cond, msg) macro
that prints to std::cerr and increments a failure counter; main returns failures != 0.
Cases:

  • NodeMatcher{{"spotify"}} matches "spotify"; does not match "spotify2" or "xspotify"
    (anchoring).
  • NodeMatcher{{"firefox|chromium"}} matches "firefox" and "chromium"; does not match
    "myfirefox" or "chromium2" (proves the ^(?:…)$ wrapping, which ^a|b$ would get wrong).
  • NodeMatcher{{"alsa_input\\..*"}} matches "alsa_input.usb-mic.mono-fallback"; does not match
    "alsa_output.pci-0000_00.analog-stereo".
  • NodeMatcher{{"a.*", "b"}} matches "abc" and "b", not "c".
  • Entry ranking: NodeMatcher{{"exact-node", "stream.*"}} returns FirstMatchingEntry 0 for
    "exact-node" and 1 for "stream-x"; with the overlapping spec
    NodeMatcher{{"stream-2", "stream.*"}}, "stream-2" reports entry 0 (first matching entry
    wins, which is what deduplicates it in the ranked query) and "stream-7" reports 1.
  • NodeMatcher{{"("}} → IsEmpty() true, matches nothing; NodeMatcher{{"(", "b"}} →
    IsEmpty() false, FirstMatchingEntry("b") is 0 (the dropped bad entry must not leave a
    phantom index ahead of the survivors).
  • Default-constructed NodeMatcher → IsEmpty() true, Matches("anything") false,
    FirstMatchingEntry("anything") empty.
  • ExpandFriendlyNames: alias → single entry replaces it; alias → list splices in order inside a
    surrounding list ({"x", "games", "y"} with games = {"steam", "lutris"} yields
    {"x", "steam", "lutris", "y"} — order matters now that entry position picks the
    representative); a non-alias entry is untouched; an alias whose value contains another alias's
    key is left as literal text (single-level expansion).

The bound-id half of the ranking lives in AudioService::GetMatchingNodes and needs live
WirePlumber proxies, so it is covered by the session checks in Verification, not by this target.

CMakeLists.txt additions (after the existing install(...) block, before CPack):

option(NANOKONTROLLER2_BUILD_TESTS "Build unit tests" OFF)
if(NANOKONTROLLER2_BUILD_TESTS)
    enable_testing()
    add_executable(nanoKontroller2_tests
            tests/NodeMatcher_tests.cpp
            src/NodeMatcher.cpp
            src/AppSettings.cpp)
    target_include_directories(nanoKontroller2_tests PRIVATE includes)
    target_link_libraries(nanoKontroller2_tests PRIVATE glaze::glaze)
    add_test(NAME nanoKontroller2_tests COMMAND nanoKontroller2_tests)
endif()

Default OFF keeps the Debian packaging and the existing build workflow untouched. tests/ sits
outside src/, so the file(GLOB_RECURSE SOURCES "src/*.cpp") of the main target is unaffected.
The test target links glaze (required by AppSettings.cpp) but never WirePlumber.

Critical files & anchors

  • src/AudioService.cpp — on_node_added / on_node_removed / on_link_added /
    on_link_removed and the four *Listener methods: every name-keyed lookup becomes a
    subscription scan; GetMatchingNodes defines the representative order; on_node_added also
    gains the media.class filter.
  • src/Actions/Action_Connection.cpp — the only action with two node slots; Pairs(),
    EnforceDesired(), the representative-pair read and the "enforce on node events, never on link
    events" split all live here.
  • src/Actions/Action_Mute.cpp — the reference implementation of representative read plus
    fan-out write; Action_Volume and Action_Connection follow its shape.
  • src/AppSettings.cpp — the parser rewrite to glz::json_t; the only place glaze is used.
  • includes/Actions/IAction.h — m_Node → m_Target, MatchedNodes/RepresentativeNode, and
    ActionParams in Init/Init_Internal and in the Registrator factory lambda.

Verification

Prerequisites: build deps per the README; run from the repo root.

  1. Build with tests and run them:
    cmake -B build -DCMAKE_BUILD_TYPE=Debug -DNANOKONTROLLER2_BUILD_TESTS=ON
    cmake --build build -j$(nproc)
    ctest --test-dir build --output-on-failure
    
    All cases of step 9 must pass; the anchoring case (firefox|chromium not matching
    myfirefox), the entry-ranking cases, and the dropped-bad-entry index case are the three that
    catch the likely mistakes.
  2. Confirm the default build is unchanged:
    cmake -B build-rel -DCMAKE_BUILD_TYPE=Release && cmake --build build-rel -j$(nproc)
    produces build-rel/nanoKontroller2 and no test target.
  3. Live check against PipeWire with a config exercising lists, patterns and aliases — needs a
    running PipeWire/WirePlumber session, the nanoKONTROL2 connected, and two or more audio streams
    (e.g. two pw-play instances). Use wpctl status to pick real names and ids.
    {
      "FriendlyNames": {
        "players": ["pw-play", "firefox.*"]
      },
      "actions": {
        "Mute1": { "type": "Mute", "params": { "target": "players" } },
        "Fader1": { "type": "volume", "params": { "target": ["players", "alsa_output\\..*"] } }
      }
    }
    
    • Start two pw-play streams, run ./build/nanoKontroller2 --config <that file>, press
      Mute1: wpctl status / wpctl get-volume <id> must show both streams muted (writes
      fan out) and the Mute1 LED lit.
    • With the action muted, start a third pw-play: the new stream must come up muted (sticky
      state) and the LED must stay lit.
    • Representative read: externally unmute the higher-id matched stream
      (wpctl set-mute <id> 0) — the LED must stay lit, because the representative (lowest bound
      id of the first matching entry) is still muted. Then unmute the lowest-id matched stream:
      the LED must go out. The next press then mutes everything again.
    • Representative promotion: with the action muted, stop the lowest-id matched stream. The
      representative becomes the next-ranked node; the LED must reflect that node's state (lit,
      since sticky state muted it too).
    • Stop every matched stream: the LED must go out and a press must do nothing.
    • Move Fader1, then start another pw-play: the new stream must come up at the fader's
      volume, not 100%.
  4. Live check of connection cross product — needs two source nodes and one sink:
    {
      "actions": {
        "Solo1": {
          "type": "connection",
          "params": {
            "left": ["pw-play", "firefox.*"],
            "right": "alsa_output\\..*analog-stereo",
            "default-state": "disconnected"
          }
        }
      }
    }
    
    Press Solo1 with two matching sources live: pw-link -l must show links from both
    sources to the sink and the LED lit. Press again: all those links disappear, LED out. With the
    action in the connected state, start a third matching source: it must be linked automatically.
    Then pw-link -d only the non-representative source's link: the LED must stay lit (the
    representative pair is still linked). Disconnect the representative pair: the LED must go out.
    Finally, with the action disconnected, create one link by hand with pw-link: it must survive
    (no teardown on link events).
  5. Invalid-pattern handling: add "target": "(" to an action and start the daemon — stderr must
    carry the invalid node pattern warning, the daemon must keep running, and the other actions
    must still work.
  6. Non-audio nodes are unreachable: with a webcam or screen-share node present and an action
    targeting ".*", wpctl status must show the video node untouched after a press.

Assumptions & contingencies

  • The representative rule applies to every read-back state, i.e. to connection's LED as well as
    mute's. If connection should instead light only when all pairs are linked, change
    Action_Connection::RefreshState to std::ranges::all_of over Pairs(); nothing else moves.
  • Alias expansion applies to every parameter value, not only node-valued ones (matching
    today's behaviour, where the main.cpp loop substituted all values). A FriendlyNames key that
    collides with a literal like connected would therefore be substituted into default-state.
    If that ever bites, restrict expansion to the keys target, left, right.
  • glz::json_t is assumed to be available in the installed glaze with the accessors listed in
    step 2 (unverified — confirm first; glaze is not installed in the planning environment). If
    that DOM type is unavailable, parse instead into a hand-written glz::meta-annotated struct
    whose node-valued fields are std::variant<std::string, std::vector<std::string>>, keeping the
    normalisation to std::vector<std::string> and everything downstream identical.
  • Matching is against node.name only — the single property the daemon reads and caches. If
    matching on node.description or application.name is wanted later, it is a separate change to
    the cache, not to NodeMatcher.
  • A pattern matching zero nodes at startup is silent (a node may legitimately appear later); only
    invalid patterns warn.
## Problem Allow matching nodes by wildcard/regex. Also allow a list wherever a node can be specified. Each list entry can be a wildcard, direct match or friendly name. Today every node reference is one exact `node.name` string. `ActionConfig::params` is `std::unordered_map<std::string, std::string>`, so a JSON array cannot even be parsed; `IActionBase::m_Node`, `Action_Connection::m_Left/m_Right` each hold one name; and `AudioService` is keyed by exact name throughout (`Nodes`, `NodeListeners` by name, `ConnectionListeners` by the literal string `left + "->" + right`). Resolved semantics for this feature: - A node entry is a **regex** (`std::regex`, ECMAScript grammar, case-sensitive), implicitly anchored by compiling `"^(?:" + entry + ")$"`. The non-capturing group is required: `^a|b$` would anchor only each alternative's outer edge. - Anywhere a node may be named (`target`, `left`, `right`, and `FriendlyNames` values), the JSON value may be a string or an array of strings. Every entry is independently a regex, a literal name (a literal is just a regex that matches itself) or a `FriendlyNames` alias. - An alias is resolved before matching, by exact whole-string lookup, and may expand to one entry or to a list that is spliced into the surrounding list. Expansion is single-level: entries produced by an alias are not themselves re-resolved, so no cycle detection is needed. - **Writes fan out, reads take the representative.** A MIDI press applies to *every* matched node (every left×right pair for `connection`), but the state an action reads back — and therefore what its LED shows — comes from a single **representative** node: the first configured entry that currently has a live match, and within that entry the match with the lowest PipeWire bound id (the oldest surviving node). A user who wants a specific node to drive the LED puts a sufficiently specific entry first. With no live match there is no representative: the action reads as not-muted / not-connected, the LED is off and a press is a no-op. Consequence, by design: when the representative is muted externally the LED lights while other matches stay unmuted, and the next press (desired = negation of what the LED shows) unmutes everything. - State is **sticky**: once an action has commanded a state, a node that starts matching later is pushed to that state (`mute` after the first press, `volume` after the first MIDI move, `connection` from startup when `default-state` is present). For `connection` this replaces today's "re-enforce `default-state` whenever a node reappears", which clobbers manual toggles. - `connection` manages the full **cross product** of left and right matches. - The node cache is restricted to nodes whose `media.class` contains `Audio`, so a broad pattern cannot reach cameras, screen-share or the nanoKONTROL2's own MIDI node. ## Plan ### 1. `NodeMatcher`: the pattern primitive New `includes/NodeMatcher.h` + `src/NodeMatcher.cpp`. It must include only `<cstddef>`, `<optional>`, `<regex>`, `<string>`, `<vector>` (plus `<iostream>` in the .cpp) — no glib/WirePlumber — so the test target can link it without PipeWire. No equivalent exists in the repo. Entries stay **individually addressable**: the representative rule needs to know *which* entry matched, so the matcher must not collapse its entries into a single `any_of` predicate. ```cpp // Matches PipeWire node names against the regex entries of one configured node // specification. Entries are already FriendlyNames-expanded; each is anchored // so a plain name matches only itself. Entry order is the configured order and // is significant: it ranks matches for the caller. class NodeMatcher { public: NodeMatcher() = default; explicit NodeMatcher(const std::vector<std::string>& patterns); // Index of the first entry matching nodeName, or nullopt when none does. [[nodiscard]] std::optional<std::size_t> FirstMatchingEntry(const std::string& nodeName) const; [[nodiscard]] bool Matches(const std::string& nodeName) const; [[nodiscard]] bool IsEmpty() const; [[nodiscard]] const std::vector<std::string>& Patterns() const; private: std::vector<std::regex> m_Regexes; std::vector<std::string> m_Patterns; }; ``` - Constructor: for each entry compile `std::regex("^(?:" + entry + ")$", std::regex::ECMAScript)`. Catch `const std::regex_error& e`, write to `std::cerr` `Warning: invalid node pattern "<entry>": <e.what()> – entry ignored.` and skip that entry. A bad entry must never abort startup, and must not disable the other entries of the same list. Compilation happens once here; node events never recompile. A skipped entry is dropped from both vectors, so the remaining entries keep their relative order (indices shift, which is harmless — only relative order is used). - `FirstMatchingEntry`: linear scan in order, returning the first index whose `std::regex_match` succeeds. - `Matches`: `FirstMatchingEntry(nodeName).has_value()`. - `IsEmpty`: `m_Regexes.empty()` — no usable entry; actions use it to decide they are inactive. `m_Patterns` keeps the source text for log messages. ### 2. Config schema: string-or-list params `includes/AppSettings.h` — normalise every parameter to a list, so there is one type everywhere and scalars are simply one-element lists: ```cpp using ActionParams = std::unordered_map<std::string, std::vector<std::string>>; struct ActionConfig { std::string type; ActionParams params; }; struct AppSettings { std::unordered_map<std::string, std::vector<std::string>> FriendlyNames; std::unordered_map<std::string, ActionConfig> Actions; }; // Single value of a scalar parameter, or nullptr when absent or empty. const std::string* GetScalarParam(const ActionParams& params, const std::string& key); // Replaces every entry that is exactly a FriendlyNames key with that alias's // entries, splicing lists in place. Alias values are not themselves resolved. void ExpandFriendlyNames(AppSettings& settings); ``` `GetScalarParam` returns `&list.front()`; when the list holds more than one entry it first warns on `std::cerr` (`Warning: parameter "<key>" expects a single value, using the first of N.`). Keep `AppSettings.h` free of glaze includes — the header is pulled in by the action headers. `src/AppSettings.cpp` — replace the direct `glz::read_file_json(Settings, …)` reflection with a `glz::json_t` DOM walk, because the parsed shape is now polymorphic (string **or** array) per key: - `glz::json_t Root; const auto Error = glz::read_file_json(Root, configFile, Buffer);` — keep the existing failure message and the "Config loaded from …" success message verbatim. - Static helper `bool ToStringList(const glz::json_t& value, std::vector<std::string>& out, const std::string& context)`: a string yields one entry; an array yields one entry per element, rejecting non-string elements with `Warning: <context>: list entries must be strings – entry ignored.`; anything else warns `Warning: <context>: expected a string or a list of strings – ignored.` and returns false. - Read key `FriendlyNames` (object) through `ToStringList` per value. - Read the actions object under key `actions`, falling back to `Actions` when `actions` is absent. Both spellings are accepted because the README and `config-example.json` document lowercase `actions` while the previous struct field was `Actions`; which one the installed glaze reflection actually accepted cannot be checked here, so neither existing config is broken. - Per action: `type` (string, required — warn and skip the action when missing), `params` (object, optional) with each value through `ToStringList`. - Call `ExpandFriendlyNames(Settings)` before returning, so actions never see aliases. - `glz::json_t` accessor names (`is_object()`, `is_array()`, `is_string()`, `get_object()`, `get_array()`, `get<std::string>()`) are *unverified — confirm first* against the installed glaze headers; glaze is not installed in the planning environment. They are a mechanical API lookup, not a design choice. `ExpandFriendlyNames` walks every `ActionConfig::params` value list and builds a replacement list: an entry that is exactly a key of `FriendlyNames` contributes that alias's entries in order; any other entry contributes itself. Alias output is appended as-is (no re-lookup). This replaces the loop in `src/main.cpp:140-144`, which substituted whole param values only; delete that loop. ### 3. Action API carries lists Thread `ActionParams` through in place of `std::unordered_map<std::string, std::string>` — `ActionFactoryRegistry::BuildAction` and its `FactoryFunction` typedef, the lambda inside `IAction::Registrator`, `IActionBase::Init`, and the `Init_Internal` override in all three actions. `includes/Actions/ActionFactoryRegistry.h` and `includes/Actions/IAction.h` include `AppSettings.h` for the alias. No other callers exist (`main.cpp` passes `action.second.params` straight through). In `IActionBase` (`includes/Actions/IAction.h` / `src/Actions/IAction.cpp`), replace `std::string m_Node` with `NodeMatcher m_Target`, built in `Init` from `params` entry `target` (absent → default-constructed, i.e. empty). Add two protected conveniences used by every action: ```cpp // Matched node names in representative order: by configured entry, then by // ascending PipeWire bound id. Empty when nothing matches. [[nodiscard]] std::vector<std::string> MatchedNodes() const; // The representative node name, or nullopt when nothing matches. [[nodiscard]] std::optional<std::string> RepresentativeNode() const; ``` `MatchedNodes` forwards to `m_audioService->GetMatchingNodes(m_Target)`; `RepresentativeNode` returns `front()` of that list, or `std::nullopt` when it is empty. ### 4. `AudioService`: pattern-based cache, listeners and ranked queries `includes/AudioService.h` includes `NodeMatcher.h`. Replace both listener maps with subscription vectors, since a listener is no longer identified by a name key: ```cpp struct NodeSubscription { NodeMatcher Matcher; IActionBase* Listener; }; struct LinkSubscription { NodeMatcher Left; NodeMatcher Right; IActionBase* Listener; }; std::vector<NodeSubscription> NodeListeners; std::vector<LinkSubscription> ConnectionListeners; ``` - `RegisterNodeListener(const NodeMatcher& matcher, IActionBase* listener)` and `RegisterLinkListener(const NodeMatcher& left, const NodeMatcher& right, IActionBase* listener)` replace the name-keyed overloads. Keep the existing duplicate-registration guard (skip when the same `listener` pointer is already subscribed). - `UnregisterNodeListener` / `UnregisterLinkListener` keep their signatures but are rewritten as `std::erase_if(NodeListeners, [listener](const NodeSubscription& s) { return s.Listener == listener; });` The current bodies loop `for (auto Itr = val.rbegin(); Itr != val.rbegin(); ++Itr)` — condition `rbegin() != rbegin()` — so they never execute; the replacement actually unsubscribes. - New ranked query, the single place the representative order is defined: ```cpp // Cached node names matching the matcher, ordered by configured entry index // then by ascending PipeWire bound id. A node matched by several entries // appears once, at its first matching entry. front() is the representative. std::vector<std::string> GetMatchingNodes(const NodeMatcher& matcher) const; ``` Implementation: for each `[name, node]` in `Nodes`, take `matcher.FirstMatchingEntry(name)`; when set, collect `{entryIndex, wp_proxy_get_bound_id(WP_PROXY(node.get())), name}`. Sort by `(entryIndex, boundId)` and project out the names. The two sort keys make the result independent of `Nodes`' `unordered_map` iteration order, which changes on rehash — i.e. an unrelated node appearing must never reorder an action's matches. Collecting by first matching entry inherently deduplicates. - `DoesNodeExist` loses its only caller (`Action_Connection::on_node_available`); delete it together with that call. - `GetMute` / `SetMute` / `SetVolume` / `GetConnectionState` / `SetConnectionState` keep their per-name signatures unchanged: matching is resolved by the action, mutation stays per node. - `on_node_added` (after reading `node.name`): read `wp_pipewire_object_get_property(WP_PIPEWIRE_OBJECT(Node), "media.class")`; when it is null or does not contain the substring `"Audio"`, return before caching. This filters in C++ rather than via a `wp_object_manager_add_interest` constraint so no unverifiable WirePlumber constraint syntax is involved; `wp_pipewire_object_get_property` is already used on the adjacent line. `on_node_removed` needs no filter — erasing an absent name is a no-op, and a non-cached node reaches no listener since dispatch is now driven by the subscriptions. - `on_node_added`, `on_node_removed`, `on_node_params_changed`, `on_node_ports_changed`: replace the `NodeListeners.find(Name)` lookup with a loop over `NodeListeners` calling the listener when `Subscription.Matcher.Matches(Name)`. Dispatch stays "any match": an action must be notified about every node it controls, not only its representative, because a non-representative node appearing still needs sticky state pushed onto it and can change which node is representative. - `on_link_added` / `on_link_removed`: keep the existing id→name resolution loop producing `LeftNodeName` / `RightNodeName`, then replace the `ConnectionListeners.find(left + "->" + right)` lookup with a loop over `ConnectionListeners` notifying when `Sub.Left.Matches(LeftNodeName) && Sub.Right.Matches(RightNodeName)`. ### 5. `Action_Mute`: representative read, fan-out write, sticky after first press State: keep `m_bIsMuted` (now the representative's state) and `m_bWaitingForLow`; add `bool m_bDesiredMute{false}` and `bool m_bHasDesired{false}`. - `Init_Internal`: when `m_Target.IsEmpty()`, keep the existing warning and return without registering. Otherwise `m_audioService->RegisterNodeListener(m_Target, this)`. - Private `void RefreshState()`: ```cpp const auto Representative = RepresentativeNode(); const bool bNew = Representative.has_value() && m_audioService->GetMute(*Representative); ``` Update the LED (guarded by `LedManager::IsLedCapable`) only when the value changed, as today. One `GetMute` call per event regardless of how many nodes match. - `on_midi(127)` (first high, `m_bWaitingForLow` false): fetch `MatchedNodes()`; when empty, set `m_bWaitingForLow = true` and return without touching state or LED (no-op press). Otherwise `m_bDesiredMute = !m_bIsMuted; m_bHasDesired = true;` call `SetMute(node, m_bDesiredMute)` for **every** matched node, set `m_bIsMuted = m_bDesiredMute` and update the LED. `value == 0` clears `m_bWaitingForLow` unchanged. - `on_node_available`: when `m_bHasDesired`, `m_audioService->SetMute(name, m_bDesiredMute)` for the node that just appeared, then `RefreshState()` (the new node may be the representative). Before the first press no state is pushed. - `on_node_params_changed` and a new `on_node_removed` override: `RefreshState()`. The removal override is needed because losing the representative promotes the next-ranked node. - The `name == m_Node` guards in the existing handlers disappear: `AudioService` only dispatches to matching subscriptions now, and a params change on *any* matched node must re-read the representative (its own change may be what the LED should show). ### 6. `Action_Volume`: applies to all matches, sticky after first move Add `float m_LastVolume{0.0f}` and `bool m_bHasVolume{false}`. Volume has no read-back state, so the representative plays no part here. - `Init_Internal`: keep the existing `pow` parsing but source the value via `GetScalarParam(params, "pow")`. Then, when `!m_Target.IsEmpty()`, `m_audioService->RegisterNodeListener(m_Target, this)` — the action registers no listener today. When the matcher is empty, warn `Warning: Volume action has no "target" node specified – action will be inactive.` (mirrors the mute warning, which has no volume counterpart today) and return. - `on_midi`: `m_LastVolume = ::ConvertVolume(value, m_PowOverride); m_bHasVolume = true;` then `SetVolume(name, m_LastVolume)` for every name in `MatchedNodes()`. - New `on_node_available` override: when `m_bHasVolume`, `SetVolume(name, m_LastVolume)` for the node that appeared. ### 7. `Action_Connection`: cross-product writes, representative-pair read Replace `std::string m_Left/m_Right` with `NodeMatcher m_Left/m_Right`; keep `m_bIsConnected` (the representative pair's state) and `m_bWaitingForLow`; replace `m_bDefaultState` with `bool m_bDesiredState{false}` plus `bool m_bHasDesired{false}`. - `Init_Internal`: build the matchers from params entries `left` and `right`, keeping the existing missing-parameter warnings (also warn when a present entry list compiles to an empty matcher). When both matchers are non-empty: `RegisterNodeListener(m_Left, this)`, `RegisterNodeListener(m_Right, this)`, `RegisterLinkListener(m_Left, m_Right, this)` — once; the current code calls `RegisterLinkListener` twice. `default-state` via `GetScalarParam(params, "default-state")`: when present set `m_bDesiredState = (*value == "connected"); m_bHasDesired = true;` else keep the existing "default state enforcement disabled" warning and leave `m_bHasDesired` false. - Private `std::vector<std::pair<std::string, std::string>> Pairs() const`: cross product of `GetMatchingNodes(m_Left)` × `GetMatchingNodes(m_Right)` in that ranked order (left outer, right inner), skipping any pair whose two names are equal (a node cannot be linked to itself; both matchers can match the same node when the patterns overlap). `Pairs().front()` is therefore the representative pair: first-ranked left with first-ranked right. - Private `void RefreshState()`: `const auto P = Pairs();` `m_bIsConnected = !P.empty() && m_audioService->GetConnectionState(P.front().first, P.front().second);` then `Refresh_LED_State()` (kept as-is). One `GetConnectionState` call per event. - Private `void EnforceDesired()`: for **each** pair whose `GetConnectionState` differs from `m_bDesiredState`, call `SetConnectionState(left, right, m_bDesiredState)`. Idempotent, so calling it per event is safe. - `on_midi(127)` (first high): `const auto P = Pairs();` when empty, set `m_bWaitingForLow = true` and return (no-op press). Otherwise `m_bDesiredState = !m_bIsConnected; m_bHasDesired = true;` `SetConnectionState(l, r, m_bDesiredState)` for every pair, `m_bIsConnected = m_bDesiredState`, `Refresh_LED_State()`. - `on_node_available` and `on_node_ports_changed` (keep the `portsNum == 0` early return): when `m_bHasDesired` call `EnforceDesired()`, then `RefreshState()`. The `DoesNodeExist` check at the top of `on_node_available` goes away — `Pairs()` is empty when either side has no live match, which `RefreshState` already handles. - `on_link_created` / `on_link_removed`: `RefreshState()` only — never `EnforceDesired()`, so a link a user creates by hand is reflected in the LED instead of being torn down immediately. Drop the `leftNodeName == m_Left` guards; `AudioService` only dispatches matching pairs. - Add an `on_node_removed` override calling `RefreshState()`, so the read follows a changed representative pair. ### 8. `main.cpp` Delete the FriendlyNames substitution loop (`src/main.cpp:140-144`) — now done in `ExpandFriendlyNames` during config load. Everything else in the action-build loop is unchanged; `action.second.params` simply has the new type. ### 9. Unit-test target `tests/NodeMatcher_tests.cpp`, dependency-free (no Catch2/doctest): a `CHECK(cond, msg)` macro that prints to `std::cerr` and increments a failure counter; `main` returns `failures != 0`. Cases: - `NodeMatcher{{"spotify"}}` matches `"spotify"`; does **not** match `"spotify2"` or `"xspotify"` (anchoring). - `NodeMatcher{{"firefox|chromium"}}` matches `"firefox"` and `"chromium"`; does **not** match `"myfirefox"` or `"chromium2"` (proves the `^(?:…)$` wrapping, which `^a|b$` would get wrong). - `NodeMatcher{{"alsa_input\\..*"}}` matches `"alsa_input.usb-mic.mono-fallback"`; does not match `"alsa_output.pci-0000_00.analog-stereo"`. - `NodeMatcher{{"a.*", "b"}}` matches `"abc"` and `"b"`, not `"c"`. - Entry ranking: `NodeMatcher{{"exact-node", "stream.*"}}` returns `FirstMatchingEntry` `0` for `"exact-node"` and `1` for `"stream-x"`; with the overlapping spec `NodeMatcher{{"stream-2", "stream.*"}}`, `"stream-2"` reports entry `0` (first matching entry wins, which is what deduplicates it in the ranked query) and `"stream-7"` reports `1`. - `NodeMatcher{{"("}}` → `IsEmpty()` true, matches nothing; `NodeMatcher{{"(", "b"}}` → `IsEmpty()` false, `FirstMatchingEntry("b")` is `0` (the dropped bad entry must not leave a phantom index ahead of the survivors). - Default-constructed `NodeMatcher` → `IsEmpty()` true, `Matches("anything")` false, `FirstMatchingEntry("anything")` empty. - `ExpandFriendlyNames`: alias → single entry replaces it; alias → list splices in order inside a surrounding list (`{"x", "games", "y"}` with `games = {"steam", "lutris"}` yields `{"x", "steam", "lutris", "y"}` — order matters now that entry position picks the representative); a non-alias entry is untouched; an alias whose value contains another alias's key is left as literal text (single-level expansion). The bound-id half of the ranking lives in `AudioService::GetMatchingNodes` and needs live WirePlumber proxies, so it is covered by the session checks in Verification, not by this target. `CMakeLists.txt` additions (after the existing `install(...)` block, before CPack): ```cmake option(NANOKONTROLLER2_BUILD_TESTS "Build unit tests" OFF) if(NANOKONTROLLER2_BUILD_TESTS) enable_testing() add_executable(nanoKontroller2_tests tests/NodeMatcher_tests.cpp src/NodeMatcher.cpp src/AppSettings.cpp) target_include_directories(nanoKontroller2_tests PRIVATE includes) target_link_libraries(nanoKontroller2_tests PRIVATE glaze::glaze) add_test(NAME nanoKontroller2_tests COMMAND nanoKontroller2_tests) endif() ``` Default `OFF` keeps the Debian packaging and the existing build workflow untouched. `tests/` sits outside `src/`, so the `file(GLOB_RECURSE SOURCES "src/*.cpp")` of the main target is unaffected. The test target links glaze (required by `AppSettings.cpp`) but never WirePlumber. ## Critical files & anchors - `src/AudioService.cpp` — `on_node_added` / `on_node_removed` / `on_link_added` / `on_link_removed` and the four `*Listener` methods: every name-keyed lookup becomes a subscription scan; `GetMatchingNodes` defines the representative order; `on_node_added` also gains the `media.class` filter. - `src/Actions/Action_Connection.cpp` — the only action with two node slots; `Pairs()`, `EnforceDesired()`, the representative-pair read and the "enforce on node events, never on link events" split all live here. - `src/Actions/Action_Mute.cpp` — the reference implementation of representative read plus fan-out write; `Action_Volume` and `Action_Connection` follow its shape. - `src/AppSettings.cpp` — the parser rewrite to `glz::json_t`; the only place glaze is used. - `includes/Actions/IAction.h` — `m_Node` → `m_Target`, `MatchedNodes`/`RepresentativeNode`, and `ActionParams` in `Init`/`Init_Internal` and in the `Registrator` factory lambda. ## Verification Prerequisites: build deps per the README; run from the repo root. 1. Build with tests and run them: ```bash cmake -B build -DCMAKE_BUILD_TYPE=Debug -DNANOKONTROLLER2_BUILD_TESTS=ON cmake --build build -j$(nproc) ctest --test-dir build --output-on-failure ``` All cases of step 9 must pass; the anchoring case (`firefox|chromium` not matching `myfirefox`), the entry-ranking cases, and the dropped-bad-entry index case are the three that catch the likely mistakes. 2. Confirm the default build is unchanged: `cmake -B build-rel -DCMAKE_BUILD_TYPE=Release && cmake --build build-rel -j$(nproc)` produces `build-rel/nanoKontroller2` and no test target. 3. Live check against PipeWire with a config exercising lists, patterns and aliases — needs a running PipeWire/WirePlumber session, the nanoKONTROL2 connected, and two or more audio streams (e.g. two `pw-play` instances). Use `wpctl status` to pick real names and ids. ```json { "FriendlyNames": { "players": ["pw-play", "firefox.*"] }, "actions": { "Mute1": { "type": "Mute", "params": { "target": "players" } }, "Fader1": { "type": "volume", "params": { "target": ["players", "alsa_output\\..*"] } } } } ``` - Start two `pw-play` streams, run `./build/nanoKontroller2 --config <that file>`, press `Mute1`: `wpctl status` / `wpctl get-volume <id>` must show **both** streams muted (writes fan out) and the `Mute1` LED lit. - With the action muted, start a third `pw-play`: the new stream must come up muted (sticky state) and the LED must stay lit. - **Representative read:** externally unmute the *higher*-id matched stream (`wpctl set-mute <id> 0`) — the LED must stay lit, because the representative (lowest bound id of the first matching entry) is still muted. Then unmute the *lowest*-id matched stream: the LED must go out. The next press then mutes everything again. - **Representative promotion:** with the action muted, stop the lowest-id matched stream. The representative becomes the next-ranked node; the LED must reflect that node's state (lit, since sticky state muted it too). - Stop every matched stream: the LED must go out and a press must do nothing. - Move `Fader1`, then start another `pw-play`: the new stream must come up at the fader's volume, not 100%. 4. Live check of `connection` cross product — needs two source nodes and one sink: ```json { "actions": { "Solo1": { "type": "connection", "params": { "left": ["pw-play", "firefox.*"], "right": "alsa_output\\..*analog-stereo", "default-state": "disconnected" } } } } ``` Press `Solo1` with two matching sources live: `pw-link -l` must show links from **both** sources to the sink and the LED lit. Press again: all those links disappear, LED out. With the action in the connected state, start a third matching source: it must be linked automatically. Then `pw-link -d` only the *non*-representative source's link: the LED must stay lit (the representative pair is still linked). Disconnect the representative pair: the LED must go out. Finally, with the action disconnected, create one link by hand with `pw-link`: it must survive (no teardown on link events). 5. Invalid-pattern handling: add `"target": "("` to an action and start the daemon — stderr must carry the `invalid node pattern` warning, the daemon must keep running, and the other actions must still work. 6. Non-audio nodes are unreachable: with a webcam or screen-share node present and an action targeting `".*"`, `wpctl status` must show the video node untouched after a press. ## Assumptions & contingencies - The representative rule applies to every read-back state, i.e. to `connection`'s LED as well as `mute`'s. If `connection` should instead light only when *all* pairs are linked, change `Action_Connection::RefreshState` to `std::ranges::all_of` over `Pairs()`; nothing else moves. - Alias expansion applies to **every** parameter value, not only node-valued ones (matching today's behaviour, where the `main.cpp` loop substituted all values). A `FriendlyNames` key that collides with a literal like `connected` would therefore be substituted into `default-state`. If that ever bites, restrict expansion to the keys `target`, `left`, `right`. - `glz::json_t` is assumed to be available in the installed glaze with the accessors listed in step 2 (*unverified — confirm first*; glaze is not installed in the planning environment). If that DOM type is unavailable, parse instead into a hand-written `glz::meta`-annotated struct whose node-valued fields are `std::variant<std::string, std::vector<std::string>>`, keeping the normalisation to `std::vector<std::string>` and everything downstream identical. - Matching is against `node.name` only — the single property the daemon reads and caches. If matching on `node.description` or `application.name` is wanted later, it is a separate change to the cache, not to `NodeMatcher`. - A pattern matching zero nodes at startup is silent (a node may legitimately appear later); only invalid patterns warn.
lucas closed this issue 2026-11-12 22:41:47 +01:00
Sign in to join this conversation.
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/nanoKontroller2#3
No description provided.