policy-node.lua 16.8 KB
Newer Older
1
2
3
4
5
6
7
-- WirePlumber
--
-- Copyright © 2020 Collabora Ltd.
--    @author Julian Bouzas <julian.bouzas@collabora.com>
--
-- SPDX-License-Identifier: MIT

8
9
10
11
12
13
14
-- Receive script arguments from config.lua
local config = ...

-- ensure config.move and config.follow are not nil
config.move = config.move or false
config.follow = config.follow or false

15
16
local pending_rescan = false

17
function parseBool(var)
18
  return var and (var:lower() == "true" or var == "1")
19
20
end

21
function createLink (si, si_target, passthrough, exclusive)
22
23
  local out_item = nil
  local in_item = nil
24
25
  local si_props = si.properties
  local target_props = si_target.properties
26

27
  if si_props["item.node.direction"] == "output" then
28
29
30
    -- playback
    out_item = si
    in_item = si_target
31
32
33
34
  else
    -- capture
    in_item = si
    out_item = si_target
35
36
  end

37
38
  local passive = parseBool(si_props["node.passive"]) or
      parseBool(target_props["node.passive"])
39

40
41
42
43
44
  Log.info (
    string.format("link %s <-> %s passive:%s, passthrough:%s, exclusive:%s",
      tostring(si_props["node.name"]),
      tostring(target_props["node.name"]),
      tostring(passive), tostring(passthrough), tostring(exclusive)))
45

46
47
48
  -- create and configure link
  local si_link = SessionItem ( "si-standard-link" )
  if not si_link:configure {
49
50
    ["out.item"] = out_item,
    ["in.item"] = in_item,
51
    ["passive"] = passive,
52
53
    ["passthrough"] = passthrough,
    ["exclusive"] = exclusive,
54
55
    ["out.item.port.context"] = "output",
    ["in.item.port.context"] = "input",
56
    ["is.policy.item.link"] = true,
57
58
  } then
    Log.warning (si_link, "failed to configure si-standard-link")
59
    return
60
61
  end

62
63
64
65
  -- register
  si_link:register ()

  -- activate
66
  si_link:activate (Feature.SessionItem.ACTIVE, function (l, e)
67
68
    if e then
      Log.warning (l, "failed to activate si-standard-link: " .. tostring(e))
69
      l:remove ()
70
71
72
73
    else
      Log.info (l, "activated si-standard-link")
    end
  end)
74
75
end

76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
function isLinked(si_target)
  local target_id = si_target.id
  local linked = false
  local exclusive = false

  for l in links_om:iterate() do
    local p = l.properties
    local out_id = tonumber(p["out.item.id"])
    local in_id = tonumber(p["in.item.id"])
    linked = (out_id == target_id) or (in_id == target_id)
    if linked then
      exclusive = parseBool(p["exclusive"]) or parseBool(p["passthrough"])
      break
    end
  end
  return linked, exclusive
end

function canPassthrough (si, si_target)
  -- both nodes must support encoded formats
  if not parseBool(si.properties["item.node.supports-encoded-fmts"])
      or not parseBool(si_target.properties["item.node.supports-encoded-fmts"]) then
    return false
  end

  -- make sure that the nodes have at least one common non-raw format
  local n1 = si:get_associated_proxy ("node")
  local n2 = si_target:get_associated_proxy ("node")
  for p1 in n1:iterate_params("EnumFormat") do
    local p1p = p1:parse()
    if p1p.properties.mediaSubtype ~= "raw" then
      for p2 in n2:iterate_params("EnumFormat") do
        if p1:filter(p2) then
          return true
        end
      end
    end
  end
  return false
end

117
118
function canLink (properties, si_target)
  local target_properties = si_target.properties
119

120
121
  -- nodes must have the same media type
  if properties["media.type"] ~= target_properties["media.type"] then
122
123
124
    return false
  end

125
126
127
128
129
  -- nodes must have opposite direction, or otherwise they must be both input
  -- and the target must have a monitor (so the target will be used as a source)
  local function isMonitor(properties)
    return properties["item.node.direction"] == "input" and
          parseBool(properties["item.features.monitor"]) and
130
          not parseBool(properties["item.features.no-dsp"]) and
131
          properties["item.factory.name"] == "si-audio-adapter"
132
133
  end

134
135
  if properties["item.node.direction"] == target_properties["item.node.direction"]
      and not isMonitor(target_properties) then
136
137
138
    return false
  end

139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
  -- check link group
  local function canLinkGroupCheck (link_group, si_target, hops)
    local target_props = si_target.properties
    local target_link_group = target_props["node.link-group"]

    if hops == 8 then
      return false
    end

    -- allow linking if target has no link-group property
    if not target_link_group then
      return true
    end

    -- do not allow linking if target has the same link-group
    if link_group == target_link_group then
      return false
    end
157

158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
    -- make sure target is not linked with another node with same link group
    -- start by locating other nodes in the target's link-group, in opposite direction
    for n in linkables_om:iterate {
      Constraint { "id", "!", si_target.id, type = "gobject" },
      Constraint { "item.node.direction", "!", target_props["item.node.direction"] },
      Constraint { "node.link-group", "=", target_link_group },
    } do
      -- iterate their peers and return false if one of them cannot link
      for silink in links_om:iterate() do
        local out_id = tonumber(silink.properties["out.item.id"])
        local in_id = tonumber(silink.properties["in.item.id"])
        if out_id == n.id or in_id == n.id then
          local peer_id = (out_id == n.id) and in_id or out_id
          local peer = linkables_om:lookup {
            Constraint { "id", "=", peer_id, type = "gobject" },
          }
          if peer and not canLinkGroupCheck (link_group, peer, hops + 1) then
            return false
          end
177
178
179
        end
      end
    end
180
    return true
181
182
  end

183
184
185
  local link_group = properties["node.link-group"]
  if link_group then
    return canLinkGroupCheck (link_group, si_target, 0)
186
  end
187
  return true
188
189
end

190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
function getTargetDirection(properties)
  local target_direction = nil
  if properties["item.node.direction"] == "output" or
     (properties["item.node.direction"] == "input" and
        parseBool(properties["stream.capture.sink"])) then
    target_direction = "input"
  else
    target_direction = "output"
  end
  return target_direction
end

function getDefaultNode(properties, target_direction)
  local target_media_class =
        properties["media.type"] ..
        (target_direction == "input" and "/Sink" or "/Source")
  return default_nodes:call("get-default-node", target_media_class)
end

209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
-- Try to locate a valid target node that was explicitly defined by the user
-- Use the target.node metadata, if config.move is enabled,
-- then use the node.target property that was set on the node
-- `properties` must be the properties dictionary of the session item
-- that is currently being handled
function findDefinedTarget (properties)
  local metadata = config.move and metadata_om:lookup()
  local target_id = metadata
      and metadata:find(properties["node.id"], "target.node")
      or properties["node.target"]
  local target_direction = getTargetDirection(properties)

  if target_id and tonumber(target_id) then
    local si_target = linkables_om:lookup {
      Constraint { "node.id", "=", target_id },
    }
    if si_target and canLink (properties, si_target) then
      return si_target
    end
  end

  if target_id then
    for si_target in linkables_om:iterate() do
      local target_props = si_target.properties
      if (target_props["node.name"] == target_id or
          target_props["object.path"] == target_id) and
          target_props["item.node.direction"] == target_direction and
          canLink (properties, si_target) then
        return si_target
      end
    end
  end
  return nil
end

244
245
246
247
248
-- Try to locate a valid target node that was NOT explicitly defined by the user
-- `properties` must be the properties dictionary of the session item
-- that is currently being handled
function findUndefinedTarget (properties)
  local function findTargetByDefaultNode (properties, target_direction)
249
    local def_id = getDefaultNode(properties, target_direction)
250
251
252
253
254
    if def_id ~= Id.INVALID then
      local si_target = linkables_om:lookup {
        Constraint { "node.id", "=", def_id },
      }
      if si_target and canLink (properties, si_target) then
255
256
257
        return si_target
      end
    end
258
    return nil
259
  end
260

261
262
263
264
265
266
267
  local function findTargetByFirstAvailable (properties, target_direction)
    for si_target in linkables_om:iterate {
      Constraint { "item.node.type", "=", "device" },
      Constraint { "item.node.direction", "=", target_direction },
      Constraint { "media.type", "=", properties["media.type"] },
    } do
      if canLink (properties, si_target) then
268
        return si_target
269
270
      end
    end
271
    return nil
272
273
  end

274
  local target_direction = getTargetDirection(properties)
275
276
  return findTargetByDefaultNode (properties, target_direction)
      or findTargetByFirstAvailable (properties, target_direction)
277
278
end

279
280
281
282
283
284
285
286
287
288
289
290
function getSiLinkAndSiPeer (si, si_props)
  local self_id_key = (si_props["item.node.direction"] == "output") and
                      "out.item.id" or "in.item.id"
  local peer_id_key = (si_props["item.node.direction"] == "output") and
                      "in.item.id" or "out.item.id"
  local silink = links_om:lookup { Constraint { self_id_key, "=", si.id } }
  if silink then
    local peer_id = tonumber(silink.properties[peer_id_key])
    local peer = linkables_om:lookup {
      Constraint { "id", "=", peer_id, type = "gobject" },
    }
    return silink, peer
291
  end
292
293
  return nil, nil
end
294

295
function checkLinkable(si)
296
  -- only handle stream session items
297
298
  local si_props = si.properties
  if not si_props or si_props["item.node.type"] ~= "stream" then
299
    return false
300
301
  end

302
  -- Determine if we can handle item by this policy
303
304
  local media_role = si_props["media.role"]
  if endpoints_om:get_n_objects () > 0 and media_role ~= nil then
305
306
307
    return false
  end

308
  return true, si_props
309
310
end

311
312
si_flags = {}

313
314
315
function handleLinkable (si)
  local valid, si_props = checkLinkable(si)
  if not valid then
316
317
318
    return
  end

319
320
  -- check if we need to link this node at all
  local autoconnect = parseBool(si_props["node.autoconnect"])
321
  if not autoconnect then
322
    Log.debug (si, tostring(si_props["node.name"]) .. " does not need to be autoconnected")
323
324
325
    return
  end

326
327
  Log.info (si, string.format("handling item: %s (%s)",
      tostring(si_props["node.name"]), tostring(si_props["node.id"])))
328

329
330
331
332
  -- prepare flags table
  if not si_flags[si.id] then
    si_flags[si.id] = {}
  end
333

334
335
336
337
338
339
340
341
342
343
  -- get other important node properties
  local reconnect = not parseBool(si_props["node.dont-reconnect"])
  local exclusive = parseBool(si_props["node.exclusive"])
  local must_passthrough = parseBool(si_props["item.node.encoded-only"])

  -- find defined target
  local si_target = findDefinedTarget(si_props)
  local can_passthrough = si_target and canPassthrough(si, si_target)
  if si_target and must_passthrough and not can_passthrough then
    si_target = nil
344
  end
345

346
347
348
  -- wait up to 2 seconds for the requested target to become available
  -- this is because the client may have already "seen" a target that we haven't
  -- yet prepared, which leads to a race condition
349
  local si_id = si.id
350
  if si_props["node.target"] and si_props["node.target"] ~= "-1"
351
      and not si_target
352
353
354
355
      and not si_flags[si_id].was_handled
      and not si_flags[si_id].done_waiting then
    if not si_flags[si_id].timeout_source then
      si_flags[si_id].timeout_source = Core.timeout_add(2000, function()
356
357
358
359
360
        if si_flags[si_id] then
          si_flags[si_id].done_waiting = true
          si_flags[si_id].timeout_source = nil
          rescan()
        end
361
362
363
364
365
366
367
        return false
      end)
    end
    Log.info (si, "... waiting for target")
    return
  end

368
  -- find fallback target
369
  if not si_target then
370
371
372
373
374
    si_target = findUndefinedTarget(si_props)
    can_passthrough = si_target and canPassthrough(si, si_target)
    if si_target and must_passthrough and not can_passthrough then
      si_target = nil
    end
375
376
377
  end

  -- Check if item is linked to proper target, otherwise re-link
378
379
380
381
382
383
384
  if si_target and si_flags[si.id].was_handled then
    local si_link, si_peer = getSiLinkAndSiPeer (si, si_props)
    if si_link then
      if si_peer and si_peer.id == si_target.id then
        Log.debug (si, "... already linked to proper target")
        return
      end
385

386
387
388
389
390
391
392
393
394
395
396
      if reconnect then
        -- remove old link if active, otherwise schedule rescan
        if ((si_link:get_active_features() & Feature.SessionItem.ACTIVE) ~= 0) then
          si_link:remove ()
          Log.info (si, "... moving to new target")
        else
          pending_rescan = true
          Log.info (si, "... scheduled rescan")
          return
        end
      end
397
    end
398
  end
399

400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
  -- if the stream has dont-reconnect and was already linked before,
  -- don't link it to a new target
  if not reconnect and si_flags[si.id].was_handled then
    si_target = nil
  end

  -- check target's availability
  if si_target then
    local target_is_linked, target_is_exclusive = isLinked(si_target)
    if target_is_exclusive then
      Log.info(si, "... target is linked exclusively")
      si_target = nil
    end

    if target_is_linked then
      if exclusive or must_passthrough then
        Log.info(si, "... target is already linked, cannot link exclusively")
        si_target = nil
      else
        -- disable passthrough, we can live without it
        can_passthrough = false
      end
    end
  end

  if not si_target then
    Log.info (si, "... target not found, reconnect:" .. tostring(reconnect))

    local node = si:get_associated_proxy ("node")
    if not reconnect then
      Log.info (si, "... destroy node")
      node:request_destroy()
    end

    local client_id = node.properties["client.id"]
    if client_id then
      local client = clients_om:lookup {
437
        Constraint { "bound-id", "=", client_id, type = "gobject" }
438
439
440
441
442
443
444
445
446
      }
      if client then
        client:send_error(node["bound-id"], -2, "no node available")
      end
    end
  else
    createLink (si, si_target, can_passthrough, exclusive)
    si_flags[si.id].was_handled = true
  end
447
448
end

449
450
451
function unhandleLinkable (si)
  local valid, si_props = checkLinkable(si)
  if not valid then
452
    return
453
454
  end

455
456
  Log.info (si, string.format("unhandling item: %s (%s)",
      tostring(si_props["node.name"]), tostring(si_props["node.id"])))
457
458

  -- remove any links associated with this item
459
  for silink in links_om:iterate() do
460
461
462
463
    local out_id = tonumber (silink.properties["out.item.id"])
    local in_id = tonumber (silink.properties["in.item.id"])
    if out_id == si.id or in_id == si.id then
      silink:remove ()
464
      Log.info (silink, "... link removed")
465
    end
466
  end
467
468

  si_flags[si.id] = nil
469
470
end

471
472
473
function rescan()
  for si in linkables_om:iterate() do
    handleLinkable (si)
474
  end
475
476
477
478
479

  -- if pending_rescan, re-evaluate after sync
  if pending_rescan then
    pending_rescan = false
    Core.sync (function (c)
480
      rescan()
481
482
    end)
  end
483
484
end

485
default_nodes = Plugin.find("default-nodes-api")
486

George Kiagiadakis's avatar
George Kiagiadakis committed
487
metadata_om = ObjectManager {
488
489
490
491
492
  Interest {
    type = "metadata",
    Constraint { "metadata.name", "=", "default" },
  }
}
493
494
495

endpoints_om = ObjectManager { Interest { type = "SiEndpoint" } }

496
497
clients_om = ObjectManager { Interest { type = "client" } }

498
499
500
501
502
503
504
505
506
507
508
509
510
linkables_om = ObjectManager {
  Interest {
    type = "SiLinkable",
    -- only handle si-audio-adapter and si-node
    Constraint { "item.factory.name", "c", "si-audio-adapter", "si-node" },
  }
}

links_om = ObjectManager {
  Interest {
    type = "SiLink",
    -- only handle links created by this policy
    Constraint { "is.policy.item.link", "=", true },
511
512
  }
}
513

514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
function cleanupTargetNodeMetadata()
  local metadata = metadata_om:lookup()
  if metadata then
    local to_remove = {}
    for s, k, t, v in metadata:iterate(Id.ANY) do
      if k == "target.node" then
        if v == "-1" then
          -- target.node == -1 is useless, it means the default node
          table.insert(to_remove, s)
        else
          -- if the target.node value is the same as the default node
          -- that would be selected for this stream, remove it
          local si = linkables_om:lookup { Constraint { "node.id", "=", s } }
          local properties = si.properties
          local def_id = getDefaultNode(properties, getTargetDirection(properties))
          if tostring(def_id) == v then
            table.insert(to_remove, s)
          end
        end
      end
    end

    for _, s in ipairs(to_remove) do
      metadata:set(s, "target.node", nil, nil)
    end
  end
end

542
543
-- listen for default node changes if config.follow is enabled
if config.follow then
544
545
546
547
  default_nodes:connect("changed", function ()
    cleanupTargetNodeMetadata()
    rescan()
  end)
548
549
550
551
end

-- listen for target.node metadata changes if config.move is enabled
if config.move then
George Kiagiadakis's avatar
George Kiagiadakis committed
552
  metadata_om:connect("object-added", function (om, metadata)
553
554
    metadata:connect("changed", function (m, subject, key, t, value)
      if key == "target.node" then
555
        rescan()
556
557
558
559
560
      end
    end)
  end)
end

561
562
linkables_om:connect("objects-changed", function (om)
  rescan()
563
564
end)

565
566
linkables_om:connect("object-removed", function (om, si)
  unhandleLinkable (si)
567
568
end)

George Kiagiadakis's avatar
George Kiagiadakis committed
569
metadata_om:activate()
570
endpoints_om:activate()
571
clients_om:activate()
572
573
linkables_om:activate()
links_om:activate()