Wildcard or list matching for source/sink #3
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/nanoKontroller2#3
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.namestring.ActionConfig::paramsisstd::unordered_map<std::string, std::string>, so a JSON array cannot even be parsed;IActionBase::m_Node,Action_Connection::m_Left/m_Righteach hold one name; andAudioServiceis keyed by exact name throughout (Nodes,NodeListenersby name,ConnectionListenersby the literal stringleft + "->" + right).Resolved semantics for this feature:
std::regex, ECMAScript grammar, case-sensitive), implicitlyanchored by compiling
"^(?:" + entry + ")$". The non-capturing group is required:^a|b$would anchor only each alternative's outer edge.target,left,right, andFriendlyNamesvalues), the JSONvalue 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
FriendlyNamesalias.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.
(every left×right pair for
connection), but the state an action reads back — and thereforewhat 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.
pushed to that state (
muteafter the first press,volumeafter the first MIDI move,connectionfrom startup whendefault-stateis present). Forconnectionthis replacestoday's "re-enforce
default-statewhenever a node reappears", which clobbers manual toggles.connectionmanages the full cross product of left and right matches.media.classcontainsAudio, so a broad patterncannot reach cameras, screen-share or the nanoKONTROL2's own MIDI node.
Plan
1.
NodeMatcher: the pattern primitiveNew
includes/NodeMatcher.h+src/NodeMatcher.cpp. It must include only<cstddef>,<optional>,<regex>,<string>,<vector>(plus<iostream>in the .cpp) — noglib/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_ofpredicate.std::regex("^(?:" + entry + ")$", std::regex::ECMAScript).Catch
const std::regex_error& e, write tostd::cerrWarning: invalid node pattern "<entry>": <e.what()> – entry ignored.and skip that entry. Abad 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 whosestd::regex_matchsucceeds.Matches:FirstMatchingEntry(nodeName).has_value().IsEmpty:m_Regexes.empty()— no usable entry; actions use it to decide they are inactive.m_Patternskeeps 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 everywhereand scalars are simply one-element lists:
GetScalarParamreturns&list.front(); when the list holds more than one entry it first warnson
std::cerr(Warning: parameter "<key>" expects a single value, using the first of N.).Keep
AppSettings.hfree of glaze includes — the header is pulled in by the action headers.src/AppSettings.cpp— replace the directglz::read_file_json(Settings, …)reflection with aglz::json_tDOM 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 theexisting failure message and the "Config loaded from …" success message verbatim.
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 warnsWarning: <context>: expected a string or a list of strings – ignored.and returns false.FriendlyNames(object) throughToStringListper value.actions, falling back toActionswhenactionsis absent.Both spellings are accepted because the README and
config-example.jsondocument lowercaseactionswhile the previous struct field wasActions; which one the installed glazereflection actually accepted cannot be checked here, so neither existing config is broken.
type(string, required — warn and skip the action when missing),params(object, optional) with each value through
ToStringList.ExpandFriendlyNames(Settings)before returning, so actions never see aliases.glz::json_taccessor names (is_object(),is_array(),is_string(),get_object(),get_array(),get<std::string>()) are unverified — confirm first against the installedglaze headers; glaze is not installed in the planning environment. They are a mechanical API
lookup, not a design choice.
ExpandFriendlyNameswalks everyActionConfig::paramsvalue list and builds a replacement list:an entry that is exactly a key of
FriendlyNamescontributes that alias's entries in order; anyother 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
ActionParamsthrough in place ofstd::unordered_map<std::string, std::string>—ActionFactoryRegistry::BuildActionand itsFactoryFunctiontypedef, the lambda insideIAction::Registrator,IActionBase::Init, and theInit_Internaloverride in all threeactions.
includes/Actions/ActionFactoryRegistry.handincludes/Actions/IAction.hincludeAppSettings.hfor the alias. No other callers exist (main.cpppassesaction.second.paramsstraight through).In
IActionBase(includes/Actions/IAction.h/src/Actions/IAction.cpp), replacestd::string m_NodewithNodeMatcher m_Target, built inInitfromparamsentrytarget(absent → default-constructed, i.e. empty). Add two protected conveniences used by every action:
MatchedNodesforwards tom_audioService->GetMatchingNodes(m_Target);RepresentativeNodereturns
front()of that list, orstd::nulloptwhen it is empty.4.
AudioService: pattern-based cache, listeners and ranked queriesincludes/AudioService.hincludesNodeMatcher.h. Replace both listener maps with subscriptionvectors, since a listener is no longer identified by a name key:
RegisterNodeListener(const NodeMatcher& matcher, IActionBase* listener)andRegisterLinkListener(const NodeMatcher& left, const NodeMatcher& right, IActionBase* listener)replace the name-keyed overloads. Keep the existing duplicate-registration guard (skip when the
same
listenerpointer is already subscribed).UnregisterNodeListener/UnregisterLinkListenerkeep their signatures but are rewritten asstd::erase_if(NodeListeners, [listener](const NodeSubscription& s) { return s.Listener == listener; });The current bodies loop
for (auto Itr = val.rbegin(); Itr != val.rbegin(); ++Itr)— conditionrbegin() != rbegin()— so they never execute; the replacement actually unsubscribes.New ranked query, the single place the representative order is defined:
Implementation: for each
[name, node]inNodes, takematcher.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 resultindependent of
Nodes'unordered_mapiteration order, which changes on rehash — i.e. anunrelated node appearing must never reorder an action's matches. Collecting by first matching
entry inherently deduplicates.
DoesNodeExistloses its only caller (Action_Connection::on_node_available); delete ittogether with that call.
GetMute/SetMute/SetVolume/GetConnectionState/SetConnectionStatekeep theirper-name signatures unchanged: matching is resolved by the action, mutation stays per node.
on_node_added(after readingnode.name): readwp_pipewire_object_get_property(WP_PIPEWIRE_OBJECT(Node), "media.class"); when it is null ordoes not contain the substring
"Audio", return before caching. This filters in C++ rather thanvia a
wp_object_manager_add_interestconstraint so no unverifiable WirePlumber constraintsyntax is involved;
wp_pipewire_object_get_propertyis already used on the adjacent line.on_node_removedneeds no filter — erasing an absent name is a no-op, and a non-cached nodereaches no listener since dispatch is now driven by the subscriptions.
on_node_added,on_node_removed,on_node_params_changed,on_node_ports_changed: replacethe
NodeListeners.find(Name)lookup with a loop overNodeListenerscalling the listener whenSubscription.Matcher.Matches(Name). Dispatch stays "any match": an action must be notifiedabout 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 producingLeftNodeName/RightNodeName, then replace theConnectionListeners.find(left + "->" + right)lookup with a loop over
ConnectionListenersnotifying whenSub.Left.Matches(LeftNodeName) && Sub.Right.Matches(RightNodeName).5.
Action_Mute: representative read, fan-out write, sticky after first pressState: keep
m_bIsMuted(now the representative's state) andm_bWaitingForLow; addbool m_bDesiredMute{false}andbool m_bHasDesired{false}.Init_Internal: whenm_Target.IsEmpty(), keep the existing warning and return withoutregistering. Otherwise
m_audioService->RegisterNodeListener(m_Target, this).void RefreshState(): Update the LED (guarded byLedManager::IsLedCapable) only when the value changed, as today.One
GetMutecall per event regardless of how many nodes match.on_midi(127)(first high,m_bWaitingForLowfalse): fetchMatchedNodes(); when empty, setm_bWaitingForLow = trueand return without touching state or LED (no-op press). Otherwisem_bDesiredMute = !m_bIsMuted; m_bHasDesired = true;callSetMute(node, m_bDesiredMute)forevery matched node, set
m_bIsMuted = m_bDesiredMuteand update the LED.value == 0clears
m_bWaitingForLowunchanged.on_node_available: whenm_bHasDesired,m_audioService->SetMute(name, m_bDesiredMute)forthe node that just appeared, then
RefreshState()(the new node may be the representative).Before the first press no state is pushed.
on_node_params_changedand a newon_node_removedoverride:RefreshState(). The removaloverride is needed because losing the representative promotes the next-ranked node.
name == m_Nodeguards in the existing handlers disappear:AudioServiceonly dispatchesto 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 moveAdd
float m_LastVolume{0.0f}andbool m_bHasVolume{false}. Volume has no read-back state, sothe representative plays no part here.
Init_Internal: keep the existingpowparsing but source the value viaGetScalarParam(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 themute warning, which has no volume counterpart today) and return.
on_midi:m_LastVolume = ::ConvertVolume(value, m_PowOverride); m_bHasVolume = true;thenSetVolume(name, m_LastVolume)for every name inMatchedNodes().on_node_availableoverride: whenm_bHasVolume,SetVolume(name, m_LastVolume)for thenode that appeared.
7.
Action_Connection: cross-product writes, representative-pair readReplace
std::string m_Left/m_RightwithNodeMatcher m_Left/m_Right; keepm_bIsConnected(the representative pair's state) and
m_bWaitingForLow; replacem_bDefaultStatewithbool m_bDesiredState{false}plusbool m_bHasDesired{false}.Init_Internal: build the matchers from params entriesleftandright, keeping the existingmissing-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; thecurrent code calls
RegisterLinkListenertwice.default-stateviaGetScalarParam(params, "default-state"): when present setm_bDesiredState = (*value == "connected"); m_bHasDesired = true;else keep the existing"default state enforcement disabled" warning and leave
m_bHasDesiredfalse.std::vector<std::pair<std::string, std::string>> Pairs() const: cross product ofGetMatchingNodes(m_Left)×GetMatchingNodes(m_Right)in that ranked order (left outer, rightinner), 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 therepresentative pair: first-ranked left with first-ranked right.
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). OneGetConnectionStatecall per event.void EnforceDesired(): for each pair whoseGetConnectionStatediffers fromm_bDesiredState, callSetConnectionState(left, right, m_bDesiredState). Idempotent, socalling it per event is safe.
on_midi(127)(first high):const auto P = Pairs();when empty, setm_bWaitingForLow = trueand 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_availableandon_node_ports_changed(keep theportsNum == 0early return): whenm_bHasDesiredcallEnforceDesired(), thenRefreshState(). TheDoesNodeExistcheck at thetop of
on_node_availablegoes away —Pairs()is empty when either side has no live match,which
RefreshStatealready handles.on_link_created/on_link_removed:RefreshState()only — neverEnforceDesired(), so alink a user creates by hand is reflected in the LED instead of being torn down immediately. Drop
the
leftNodeName == m_Leftguards;AudioServiceonly dispatches matching pairs.on_node_removedoverride callingRefreshState(), so the read follows a changedrepresentative pair.
8.
main.cppDelete the FriendlyNames substitution loop (
src/main.cpp:140-144) — now done inExpandFriendlyNamesduring config load. Everything else in the action-build loop is unchanged;action.second.paramssimply has the new type.9. Unit-test target
tests/NodeMatcher_tests.cpp, dependency-free (no Catch2/doctest): aCHECK(cond, msg)macrothat prints to
std::cerrand increments a failure counter;mainreturnsfailures != 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".NodeMatcher{{"exact-node", "stream.*"}}returnsFirstMatchingEntry0for"exact-node"and1for"stream-x"; with the overlapping specNodeMatcher{{"stream-2", "stream.*"}},"stream-2"reports entry0(first matching entrywins, which is what deduplicates it in the ranked query) and
"stream-7"reports1.NodeMatcher{{"("}}→IsEmpty()true, matches nothing;NodeMatcher{{"(", "b"}}→IsEmpty()false,FirstMatchingEntry("b")is0(the dropped bad entry must not leave aphantom index ahead of the survivors).
NodeMatcher→IsEmpty()true,Matches("anything")false,FirstMatchingEntry("anything")empty.ExpandFriendlyNames: alias → single entry replaces it; alias → list splices in order inside asurrounding list (
{"x", "games", "y"}withgames = {"steam", "lutris"}yields{"x", "steam", "lutris", "y"}— order matters now that entry position picks therepresentative); 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::GetMatchingNodesand needs liveWirePlumber proxies, so it is covered by the session checks in Verification, not by this target.
CMakeLists.txtadditions (after the existinginstall(...)block, before CPack):Default
OFFkeeps the Debian packaging and the existing build workflow untouched.tests/sitsoutside
src/, so thefile(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_removedand the four*Listenermethods: every name-keyed lookup becomes asubscription scan;
GetMatchingNodesdefines the representative order;on_node_addedalsogains the
media.classfilter.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 linkevents" split all live here.
src/Actions/Action_Mute.cpp— the reference implementation of representative read plusfan-out write;
Action_VolumeandAction_Connectionfollow its shape.src/AppSettings.cpp— the parser rewrite toglz::json_t; the only place glaze is used.includes/Actions/IAction.h—m_Node→m_Target,MatchedNodes/RepresentativeNode, andActionParamsinInit/Init_Internaland in theRegistratorfactory lambda.Verification
Prerequisites: build deps per the README; run from the repo root.
firefox|chromiumnot matchingmyfirefox), the entry-ranking cases, and the dropped-bad-entry index case are the three thatcatch the likely mistakes.
cmake -B build-rel -DCMAKE_BUILD_TYPE=Release && cmake --build build-rel -j$(nproc)produces
build-rel/nanoKontroller2and no test target.running PipeWire/WirePlumber session, the nanoKONTROL2 connected, and two or more audio streams
(e.g. two
pw-playinstances). Usewpctl statusto pick real names and ids.pw-playstreams, run./build/nanoKontroller2 --config <that file>, pressMute1:wpctl status/wpctl get-volume <id>must show both streams muted (writesfan out) and the
Mute1LED lit.pw-play: the new stream must come up muted (stickystate) and the LED must stay lit.
(
wpctl set-mute <id> 0) — the LED must stay lit, because the representative (lowest boundid 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 becomes the next-ranked node; the LED must reflect that node's state (lit,
since sticky state muted it too).
Fader1, then start anotherpw-play: the new stream must come up at the fader'svolume, not 100%.
connectioncross product — needs two source nodes and one sink: PressSolo1with two matching sources live:pw-link -lmust show links from bothsources 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 -donly the non-representative source's link: the LED must stay lit (therepresentative 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).
"target": "("to an action and start the daemon — stderr mustcarry the
invalid node patternwarning, the daemon must keep running, and the other actionsmust still work.
targeting
".*",wpctl statusmust show the video node untouched after a press.Assumptions & contingencies
connection's LED as well asmute's. Ifconnectionshould instead light only when all pairs are linked, changeAction_Connection::RefreshStatetostd::ranges::all_ofoverPairs(); nothing else moves.today's behaviour, where the
main.cpploop substituted all values). AFriendlyNameskey thatcollides with a literal like
connectedwould therefore be substituted intodefault-state.If that ever bites, restrict expansion to the keys
target,left,right.glz::json_tis assumed to be available in the installed glaze with the accessors listed instep 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 structwhose node-valued fields are
std::variant<std::string, std::vector<std::string>>, keeping thenormalisation to
std::vector<std::string>and everything downstream identical.node.nameonly — the single property the daemon reads and caches. Ifmatching on
node.descriptionorapplication.nameis wanted later, it is a separate change tothe cache, not to
NodeMatcher.invalid patterns warn.