flexdeck 0.1.1__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexdeck
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Write FlexDeck plugins in Python: declare actions, values, events and feeds; test, pack and verify them against FlexDeck itself.
5
5
  License: MIT
6
6
  Keywords: flexdeck,plugin,sdk,deck,tablet
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flexdeck
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Write FlexDeck plugins in Python: declare actions, values, events and feeds; test, pack and verify them against FlexDeck itself.
5
5
  License: MIT
6
6
  Keywords: flexdeck,plugin,sdk,deck,tablet
@@ -53,6 +53,8 @@ PERMISSIONS = ("network", "notifications", "processes", "filesystem")
53
53
  MAX_SERIES_POINTS = 600
54
54
  MAX_IMAGE_BYTES = 256 * 1024
55
55
  IMAGE_MIMES = ("image/png", "image/jpeg", "image/webp")
56
+ CAPABILITIES = ("series", "presets", "feeds", "toggles")
57
+ _PLACEHOLDER = re.compile(r"\{\{\s*([^{}\s]+)\s*\}\}")
56
58
 
57
59
  Value = Union[str, float, int, bool]
58
60
 
@@ -109,6 +111,15 @@ class _Action:
109
111
  run: Callable[[Dict[str, str], "Context"], Any]
110
112
  arguments: Dict[str, Argument]
111
113
  check: Optional[Callable[[Dict[str, str], "Context"], Optional[str]]] = None
114
+ active: Optional[Dict[str, str]] = None
115
+
116
+ def active_dto(self, offered: Iterable[str]) -> Optional[Dict[str, str]]:
117
+ """The light as the host reads it: the plain kind (``equals``, no
118
+ placeholders) for any host, the rest only for one offering ``toggles``."""
119
+ if self.active is None:
120
+ return None
121
+ plain = "equals" in self.active and not _PLACEHOLDER.search(self.active["variable"])
122
+ return dict(self.active) if plain or "toggles" in offered else None
112
123
 
113
124
 
114
125
  @dataclass
@@ -311,17 +322,31 @@ class Plugin:
311
322
  """Something that happens, which an automation can be started by."""
312
323
  self._events[name] = {"description": description, "fields": list(fields)}
313
324
 
314
- def action(self, name: str, description: str, arguments: Optional[Mapping[str, Argument]] = None, *, check=None):
325
+ def action(self, name: str, description: str, arguments: Optional[Mapping[str, Argument]] = None, *, check=None, active: Optional[Mapping[str, str]] = None):
315
326
  """Decorate a function ``run(args, ctx)`` as an action. It may return
316
- ``{"state": "<look>"}`` for the key to show."""
327
+ ``{"state": "<look>"}`` for the key to show.
328
+
329
+ ``active`` says when a key running it is lit: ``{"variable": "x.muted"}``
330
+ while that is true, ``{"variable": "x.mode", "is": "ptt"}`` while it holds
331
+ that value, ``{"variable": "x.scene", "equals": "scene"}`` while it holds
332
+ the ``scene`` argument's value. The variable may hold ``{{argument}}``
333
+ placeholders."""
317
334
  arguments = dict(arguments or {})
318
335
  for arg_name, argument in arguments.items():
319
336
  _require(argument.kind in KINDS, f"{name}.{arg_name}: '{argument.kind}' is not a kind")
320
337
  if argument.kind == "choice":
321
338
  _require(argument.example in argument.choices, f"{name}.{arg_name}: the example must be one of its choices")
339
+ if active is not None:
340
+ active = dict(active)
341
+ variable = active.get("variable", "")
342
+ _require(set(active) <= {"variable", "equals", "is"}, f"{name}.active: takes variable, equals and is")
343
+ _require(not ("equals" in active and "is" in active), f"{name}.active: takes equals or is, not both")
344
+ _require(variable.startswith(f"{self.id}."), f"{name}.active: variable '{variable}' must start with '{self.id}.'")
345
+ for named in _PLACEHOLDER.findall(variable) + ([active["equals"]] if "equals" in active else []):
346
+ _require(named in arguments, f"{name}.active: '{named}' is not one of its arguments")
322
347
 
323
348
  def register(run):
324
- self._actions[name] = _Action(description=description, run=run, arguments=arguments, check=check)
349
+ self._actions[name] = _Action(description=description, run=run, arguments=arguments, check=check, active=active)
325
350
  return run
326
351
 
327
352
  return register
@@ -360,8 +385,16 @@ class Plugin:
360
385
 
361
386
  # ---- What the host is told -------------------------------------------------
362
387
 
363
- def manifest(self) -> Dict[str, Any]:
364
- """What ``initialize`` answers with."""
388
+ def manifest(self, offered: Iterable[str] = CAPABILITIES) -> Dict[str, Any]:
389
+ """What ``initialize`` answers with, to a host offering ``offered``."""
390
+ offered = tuple(offered)
391
+ actions = []
392
+ for n, a in self._actions.items():
393
+ dto: Dict[str, Any] = {"action": n, "description": a.description, "arguments": [arg.dto(k) for k, arg in a.arguments.items()]}
394
+ light = a.active_dto(offered)
395
+ if light is not None:
396
+ dto["active"] = light
397
+ actions.append(dto)
365
398
  manifest: Dict[str, Any] = {
366
399
  "abi": ABI_VERSION,
367
400
  "id": self.id,
@@ -369,10 +402,7 @@ class Plugin:
369
402
  "version": self.version,
370
403
  "description": self.description,
371
404
  "author": self.author,
372
- "actions": [
373
- {"action": n, "description": a.description, "arguments": [arg.dto(k) for k, arg in a.arguments.items()]}
374
- for n, a in self._actions.items()
375
- ],
405
+ "actions": actions,
376
406
  "settings": [
377
407
  {"key": k, "description": s["description"], "kind": s["kind"], "choices": s["choices"], "defaultValue": s["default"], "secret": s["secret"]}
378
408
  for k, s in self._settings_spec.items()
@@ -395,6 +425,8 @@ class Plugin:
395
425
  }
396
426
  for n, f in self._feeds.items()
397
427
  ]
428
+ if "toggles" in offered and any(a.active is not None and a.active_dto(()) is None for a in self._actions.values()):
429
+ uses.append("toggles")
398
430
  manifest["uses"] = uses
399
431
  return manifest
400
432
 
@@ -451,7 +483,7 @@ class Plugin:
451
483
  if asked not in SUPPORTED_ABIS:
452
484
  raise PluginError(f"this plugin speaks ABI {' or '.join(map(str, SUPPORTED_ABIS))}, the agent speaks {asked}")
453
485
  self.configure(body.get("settings") or {}, body.get("dataDir"))
454
- return {"manifest": self.manifest(), "variables": self.variables()}
486
+ return {"manifest": self.manifest(body.get("hostCapabilities") or ()), "variables": self.variables()}
455
487
  if method == "variables":
456
488
  return self.variables()
457
489
  if method == "check":
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "flexdeck"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Write FlexDeck plugins in Python: declare actions, values, events and feeds; test, pack and verify them against FlexDeck itself."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -57,6 +57,24 @@ class DefinitionTest(unittest.TestCase):
57
57
  )
58
58
  self.assertEqual(manifest["uses"], ["series"])
59
59
 
60
+ def test_a_toggle_light_reaches_only_a_host_that_offers_toggles(self):
61
+ plugin = Plugin(id="lights", name="Lights", version="1.0.0")
62
+ plugin.variable("lights.room.desk.on", "bool")
63
+ plugin.action("toggle", "Toggle", {"room": arg("text", "desk")}, active={"variable": "lights.room.{{room}}.on"})(lambda args, ctx: None)
64
+ plugin.action("pick", "Pick", {"scene": arg("text", "a")}, active={"variable": "lights.room.desk.on", "equals": "scene"})(lambda args, ctx: None)
65
+
66
+ newer = plugin.handle("initialize", {"abi": 4, "hostCapabilities": ["series", "toggles"]})["manifest"]
67
+ self.assertEqual([a.get("active") for a in newer["actions"]], [{"variable": "lights.room.{{room}}.on"}, {"variable": "lights.room.desk.on", "equals": "scene"}])
68
+ self.assertIn("toggles", newer["uses"])
69
+ older = plugin.handle("initialize", {"abi": 4, "hostCapabilities": ["series"]})["manifest"]
70
+ self.assertEqual([a.get("active") for a in older["actions"]], [None, {"variable": "lights.room.desk.on", "equals": "scene"}])
71
+ self.assertNotIn("toggles", older["uses"])
72
+
73
+ with self.assertRaisesRegex(PluginError, "'place' is not one of its arguments"):
74
+ plugin.action("bad", "Bad", {}, active={"variable": "lights.{{place}}.on"})
75
+ with self.assertRaisesRegex(PluginError, "must start with 'lights.'"):
76
+ plugin.action("bad", "Bad", {}, active={"variable": "obs.on"})
77
+
60
78
 
61
79
  class ProtocolTest(unittest.TestCase):
62
80
  def test_actions_values_events_and_failures_as_the_host_sees_them(self):
File without changes
File without changes
File without changes