Merge branch 'main' of git.stroblme.de:Fluksio/app
This commit is contained in:
@@ -100,8 +100,9 @@ class DelayNode(Node):
|
||||
class Params(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
delay: int = 0
|
||||
interval: int = 0
|
||||
# Seconds, fractional: a shutter's run-time is not a whole number.
|
||||
delay: float = 0
|
||||
interval: float = 0
|
||||
# Input port → output port; ports are paired in order when empty.
|
||||
mapping: dict[str, str] = {}
|
||||
cron: str | None = None
|
||||
|
||||
@@ -130,6 +130,8 @@ class HttpNode(Node):
|
||||
"mode",
|
||||
"timeout",
|
||||
"headers",
|
||||
"query",
|
||||
"send_inputs",
|
||||
"secret",
|
||||
"_route_registered",
|
||||
)
|
||||
@@ -141,6 +143,21 @@ class HttpNode(Node):
|
||||
method: Literal["GET", "POST"] = "POST"
|
||||
timeout: float = 30.0
|
||||
headers: dict[str, str] = {}
|
||||
query: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description=(
|
||||
"Fixed query parameters, sender mode. A value may be a secret "
|
||||
"reference, which is how an API key stays out of the flow file."
|
||||
),
|
||||
)
|
||||
send_inputs: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Send the node's inputs as the request body or query. Off when "
|
||||
"the inputs are only a trigger and the request is fully "
|
||||
"described by 'url' and 'query'."
|
||||
),
|
||||
)
|
||||
secret: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
@@ -197,6 +214,8 @@ class HttpNode(Node):
|
||||
self.method = cfg.method.upper()
|
||||
self.timeout = cfg.timeout
|
||||
self.headers = cfg.headers
|
||||
self.query = cfg.query
|
||||
self.send_inputs = cfg.send_inputs
|
||||
self.secret = cfg.secret
|
||||
self._route_registered = False
|
||||
|
||||
@@ -251,18 +270,20 @@ class HttpNode(Node):
|
||||
:rtype: dict | None
|
||||
"""
|
||||
client = shared_client()
|
||||
payload = dict(kwargs) if self.send_inputs else {}
|
||||
try:
|
||||
if self.method == "GET":
|
||||
response = client.get(
|
||||
self.url,
|
||||
params=kwargs,
|
||||
params={**self.query, **payload},
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
else: # POST
|
||||
response = client.post(
|
||||
self.url,
|
||||
json=kwargs,
|
||||
params=self.query or None,
|
||||
json=payload,
|
||||
headers=self.headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
@@ -24,6 +24,30 @@ logger = logging.getLogger(__name__)
|
||||
PUBLISH_QUEUE_SIZE = 256
|
||||
|
||||
|
||||
def topic_matches(filter_: str, topic: str) -> bool:
|
||||
"""Does an MQTT topic filter cover this topic?
|
||||
|
||||
The broker already decided to send it, so this is only about which port it
|
||||
belongs to. ``+`` stands for one level, ``#`` for the rest of them; a
|
||||
filter with neither is an exact string, which is the common case and the
|
||||
one the caller checks first.
|
||||
"""
|
||||
if filter_ == topic:
|
||||
return True
|
||||
parts = filter_.split("/")
|
||||
levels = topic.split("/")
|
||||
for i, part in enumerate(parts):
|
||||
if part == "#":
|
||||
# Everything before it already matched, and '#' takes the rest —
|
||||
# including nothing at all, so 'a/#' covers 'a' as the spec says.
|
||||
return True
|
||||
if i >= len(levels):
|
||||
return False
|
||||
if part != "+" and part != levels[i]:
|
||||
return False
|
||||
return len(parts) == len(levels)
|
||||
|
||||
|
||||
class MqttNode(Node):
|
||||
"""
|
||||
MQTT node that can act as a subscriber (trigger) or publisher (sender).
|
||||
@@ -127,7 +151,9 @@ class MqttNode(Node):
|
||||
"qos",
|
||||
"retain",
|
||||
"keepalive",
|
||||
"json_keys",
|
||||
"_topic_to_ports",
|
||||
"_wildcards",
|
||||
"_subscription_task",
|
||||
"_mqtt_client",
|
||||
"_stop_event",
|
||||
@@ -149,6 +175,10 @@ class MqttNode(Node):
|
||||
qos: int = 0
|
||||
retain: bool = False
|
||||
keepalive: int = 60
|
||||
# Which key to lift out of a JSON object payload. A device that wraps
|
||||
# its reading — Victron's ``{"value": 5}`` — is otherwise a Python node
|
||||
# per port. One key for every port, or a per-port mapping.
|
||||
json_key: str | dict[str, str] = ""
|
||||
|
||||
@classmethod
|
||||
def instance_key(cls, params: dict[str, Any]) -> str | None:
|
||||
@@ -194,10 +224,20 @@ class MqttNode(Node):
|
||||
else:
|
||||
self.topics = {spec.port: cfg.topic for spec in ports}
|
||||
|
||||
# Reverse lookup for routing incoming payloads back to ports.
|
||||
# Reverse lookup for routing incoming payloads back to ports. A filter
|
||||
# holding a wildcard cannot be found by the incoming topic, so those
|
||||
# are kept aside and walked when the exact lookup misses.
|
||||
self._topic_to_ports: dict[str, list[str]] = {}
|
||||
for port, topic in self.topics.items():
|
||||
self._topic_to_ports.setdefault(topic, []).append(port)
|
||||
self._wildcards = [t for t in self._topic_to_ports if "+" in t or "#" in t]
|
||||
|
||||
if isinstance(cfg.json_key, dict):
|
||||
self.json_keys: dict[str, str] = dict(cfg.json_key)
|
||||
else:
|
||||
self.json_keys = (
|
||||
{spec.port: cfg.json_key for spec in ports} if cfg.json_key else {}
|
||||
)
|
||||
|
||||
self.broker_host = cfg.broker_host
|
||||
self.broker_port = cfg.broker_port
|
||||
@@ -488,6 +528,17 @@ class MqttNode(Node):
|
||||
self.name,
|
||||
)
|
||||
|
||||
def _ports_for(self, topic: str) -> list[str]:
|
||||
"""Which ports an arriving topic feeds — exact mapping, then filters."""
|
||||
ports = self._topic_to_ports.get(topic)
|
||||
if ports is not None:
|
||||
return ports
|
||||
matched: list[str] = []
|
||||
for pattern in self._wildcards:
|
||||
if topic_matches(pattern, topic):
|
||||
matched.extend(self._topic_to_ports[pattern])
|
||||
return matched
|
||||
|
||||
async def _subscription_loop(self) -> None:
|
||||
"""
|
||||
Listen for MQTT messages and trigger the pipeline.
|
||||
@@ -536,7 +587,7 @@ class MqttNode(Node):
|
||||
)
|
||||
|
||||
# Find which port(s) this topic feeds
|
||||
ports = self._topic_to_ports.get(incoming_topic, [])
|
||||
ports = self._ports_for(incoming_topic)
|
||||
if not ports:
|
||||
logger.debug(
|
||||
"[%s] No mapping for topic '%s', ignoring",
|
||||
@@ -558,10 +609,12 @@ class MqttNode(Node):
|
||||
if spec is None:
|
||||
continue
|
||||
|
||||
# A JSON object may carry the port as a key;
|
||||
# anything else is the value itself.
|
||||
if isinstance(parsed, dict) and port in parsed:
|
||||
value = parsed[port]
|
||||
# A JSON object may carry the value under a
|
||||
# named key — the port's own name, or whatever
|
||||
# ``json_key`` says the device wraps it in.
|
||||
key = self.json_keys.get(port, port)
|
||||
if isinstance(parsed, dict) and key in parsed:
|
||||
value = parsed[key]
|
||||
else:
|
||||
value = parsed
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ class TriggerNode(Node):
|
||||
"""Emit ``first`` on arrival, then ``then`` once the wait runs out.
|
||||
|
||||
A value arriving during the wait extends it, so the second emission only
|
||||
happens when things have actually gone quiet.
|
||||
happens when things have actually gone quiet. ``passthrough`` sends the
|
||||
incoming value instead of ``first``, and ``wait_port`` names an input
|
||||
carrying the wait, for the case where how long to wait is itself a value —
|
||||
a shutter that takes 26 s up and 28 s down.
|
||||
"""
|
||||
|
||||
class Params(BaseModel):
|
||||
@@ -41,6 +44,17 @@ class TriggerNode(Node):
|
||||
default=True,
|
||||
description="A value arriving during the wait starts it over.",
|
||||
)
|
||||
passthrough: bool = Field(
|
||||
default=False,
|
||||
description="Send the incoming value instead of 'first'.",
|
||||
)
|
||||
wait_port: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"An input port carrying the wait in seconds, for a wait that "
|
||||
"differs per message. Zero or less sends nothing afterwards."
|
||||
),
|
||||
)
|
||||
|
||||
__slots__ = ("cfg",)
|
||||
|
||||
@@ -67,23 +81,30 @@ class TriggerNode(Node):
|
||||
if not kwargs or not self.output_ports:
|
||||
return None
|
||||
|
||||
wait = self.cfg.wait
|
||||
if self.cfg.wait_port:
|
||||
# A value port rather than a setting: a shutter's run-time differs
|
||||
# by direction, and that is a message the graph carries.
|
||||
wait = float(kwargs.get(self.cfg.wait_port, wait) or 0)
|
||||
|
||||
armed = self.recall("armed", 0)
|
||||
if armed and not self.cfg.extend:
|
||||
# Already counting down and not extending: ignore the new value.
|
||||
return None
|
||||
|
||||
# A generation counter, so a value arriving mid-wait invalidates the
|
||||
# deferred emission that was already scheduled.
|
||||
# deferred emission that was already scheduled. Bumped even when this
|
||||
# message asks for no second value, which is how a wait is cancelled.
|
||||
generation = int(self.recall("generation", 0)) + 1
|
||||
self.remember("generation", generation)
|
||||
self.remember("armed", 1)
|
||||
self.remember("armed", 1 if wait > 0 else 0)
|
||||
|
||||
if self.cfg.then is not None and self._pipeline is not None:
|
||||
if self.cfg.then is not None and self._pipeline is not None and wait > 0:
|
||||
deferred = self._outputs(self.cfg.then)
|
||||
scheduled = self._pipeline.defer(
|
||||
self,
|
||||
self._to_messages(deferred) or {},
|
||||
self.cfg.wait,
|
||||
wait,
|
||||
guard=("generation", generation),
|
||||
)
|
||||
if not scheduled:
|
||||
@@ -94,4 +115,14 @@ class TriggerNode(Node):
|
||||
)
|
||||
self.remember("armed", 0)
|
||||
|
||||
if self.cfg.passthrough:
|
||||
# Whatever woke the node, minus the port that only said how long
|
||||
# to wait. Several inputs are ambiguous, so the first one wins.
|
||||
values = {
|
||||
port: value
|
||||
for port, value in kwargs.items()
|
||||
if port != self.cfg.wait_port
|
||||
}
|
||||
if values:
|
||||
return self._outputs(next(iter(values.values())))
|
||||
return self._outputs(self.cfg.first)
|
||||
|
||||
Reference in New Issue
Block a user