feat: add real-time data feeds for various sources
- Implemented new data feeds for Blitzortung, Bluesky, GCN, GitHub, Mempool, Netzfrequenz, OpenSky, RTE éCO2mix, SWPC, USGS. - Introduced utility functions for rate limiting and hashing. - Established OSC communication for data reception and processing. - Created SuperCollider definitions for handling incoming data and generating audio output based on real-time data. - Added configuration management for API credentials and polling intervals.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(awk 'BEGIN{p=0;b=0;tlb=0;depth=0} /^\\\\\\(/{if\\(depth==0\\)tlb++; depth++} {for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b \" TLB:\" tlb}' sound_algo/control/data_feeds.scd)",
|
||||
"Bash(awk '{for\\(i=1;i<=length\\($0\\);i++\\){c=substr\\($0,i,1\\); if\\(c==\"\\(\"\\)p++; else if\\(c==\"\\)\"\\)p--; else if\\(c==\"[\"\\)b++; else if\\(c==\"]\"\\)b--}} END{print \"P:\" p \" B:\" b}' sound_algo/examples/16_data_feeds.scd sound_algo/control/data_feeds.scd)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# data_feeds — Pont flux temps réel → OSC
|
||||
|
||||
Worker Python asynchrone qui aspire des sources publiques (sismique,
|
||||
géophysique, réseau électrique, foudre, aviation, social, blockchain…)
|
||||
et les rebalance en OSC vers SuperCollider (`:57121`) et openFrameworks
|
||||
(`:57123`). Le but : nourrir l'engine audio et le visualizer avec des
|
||||
**signaux du monde réel**, sans bricoler du networking dans `sclang`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐
|
||||
│ data_feeds/bridge.py │
|
||||
│ ├─ usgs (HTTP 60 s) │
|
||||
│ ├─ swpc (HTTP 60 s) │
|
||||
│ ├─ netzfrequenz(WebSocket) │
|
||||
│ ├─ blitzortung (WebSocket) │
|
||||
│ ├─ opensky (HTTP 15 s) │
|
||||
│ ├─ bluesky (WebSocket) │
|
||||
│ ├─ mempool (WebSocket) │
|
||||
│ ├─ rte_eco2mix (OAuth2) │
|
||||
│ ├─ github (HTTP 30 s) │
|
||||
│ └─ gcn (Kafka) │
|
||||
└─────────────┬──────────────────┘
|
||||
│ OSC broadcast
|
||||
┌─────────────┴────────────┐
|
||||
UDP :57121 UDP :57123
|
||||
┌──▼────────────┐ ┌─────▼────────────┐
|
||||
│ SuperCollider │ │ openFrameworks │
|
||||
│ ~feeds dict │ │ OscClient.data()│
|
||||
└───────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
## Démarrage
|
||||
|
||||
```bash
|
||||
cd data_feeds
|
||||
uv sync # créé .venv et installe les deps
|
||||
uv run python bridge.py -v # -v = verbose
|
||||
```
|
||||
|
||||
Côté SC :
|
||||
|
||||
```supercollider
|
||||
"sound_algo/control/data_feeds.scd".loadRelative; // installe les OSCdef
|
||||
~feedDump.value; // affiche l'état
|
||||
```
|
||||
|
||||
Côté oF : automatique dès que `OscClient::update()` tourne (déjà appelé
|
||||
chaque frame). Lecture :
|
||||
|
||||
```cpp
|
||||
float kp = osc_.dataf("swpc", "kp", /*fallback*/ 2.0f);
|
||||
std::vector<float> strike;
|
||||
if (osc_.consumeDataPulse("blitzortung", "strike", strike)) {
|
||||
// strike = [lat, lon, age, mult]
|
||||
}
|
||||
```
|
||||
|
||||
## Schéma OSC
|
||||
|
||||
Toutes les routes sont préfixées `/data/<feed>/<sub>`. Voir
|
||||
[`docs/DATA_FEEDS_OSC.md`](../docs/DATA_FEEDS_OSC.md) pour le schéma
|
||||
complet.
|
||||
|
||||
| Feed | Routes | Cadence |
|
||||
|----------------|---------------------------------------------|-------------|
|
||||
| `usgs` | `event`, `rate` | 60 s |
|
||||
| `swpc` | `wind`, `bz`, `kp`, `xray` | 60 s |
|
||||
| `netzfrequenz` | `freq`, `dev`, `time_dev` | ~200 ms |
|
||||
| `blitzortung` | `strike`, `rate` | event-based |
|
||||
| `opensky` | `count`, `plane` | 15 s |
|
||||
| `bluesky` | `post`, `rate` | event-based |
|
||||
| `mempool` | `tx`, `block` | event-based |
|
||||
| `rte_eco2mix` | `mix` | 15 min |
|
||||
| `github` | `event` | 30 s |
|
||||
| `gcn` | `alert` | rare |
|
||||
|
||||
## Configuration
|
||||
|
||||
Éditer `config.toml` :
|
||||
|
||||
- `osc.targets` : liste `{host, port}` à arroser (par défaut SC + oF).
|
||||
- `feeds.<name>.enabled` : booléen.
|
||||
- `feeds.<name>.poll_seconds` : période pour les feeds HTTP.
|
||||
- `feeds.opensky.bbox` : `[lamin, lomin, lamax, lomax]` (Lyon par défaut).
|
||||
- `feeds.bluesky.sample_rate` : 0..1, fraction des posts conservée.
|
||||
|
||||
Flux nécessitant des identifiants (désactivés par défaut) :
|
||||
|
||||
- `rte_eco2mix` : créer un client sur
|
||||
<https://data.rte-france.com/> puis renseigner `client_id` /
|
||||
`client_secret`.
|
||||
- `gcn` : <https://gcn.nasa.gov/quickstart> + `uv add gcn-kafka`.
|
||||
|
||||
## Diagnostic
|
||||
|
||||
```bash
|
||||
# Sniffer les paquets recus cote SC
|
||||
uv run python -c "from pythonosc import osc_server, dispatcher; \
|
||||
d=dispatcher.Dispatcher(); d.set_default_handler(lambda a,*x: print(a,x)); \
|
||||
osc_server.BlockingOSCUDPServer(('127.0.0.1',57121),d).serve_forever()"
|
||||
```
|
||||
|
||||
Côté SC, vérifier le heartbeat :
|
||||
|
||||
```supercollider
|
||||
~feedAlive.value // true si le pont émet depuis < 15 s
|
||||
```
|
||||
|
||||
## Ajout d'un flux
|
||||
|
||||
1. Créer `data_feeds/feeds/<name>.py` exposant `async def run(ctx)`.
|
||||
2. L'enregistrer dans `config.toml` avec `enabled = true`.
|
||||
3. Ajouter les OSCdef correspondants dans
|
||||
`sound_algo/control/data_feeds.scd`.
|
||||
4. Documenter le schéma OSC dans `docs/DATA_FEEDS_OSC.md`.
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Orchestrateur du pont data_feeds → OSC.
|
||||
|
||||
Charge `config.toml`, lance un worker async par flux activé, et diffuse
|
||||
en broadcast vers tous les `osc.targets`. Chaque worker doit exposer
|
||||
une coroutine `run(ctx)` qui prend un `Context` et émet via `ctx.send(...)`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import tomllib # py311+
|
||||
except ModuleNotFoundError:
|
||||
import tomli as tomllib # type: ignore
|
||||
|
||||
from pythonosc.udp_client import SimpleUDPClient
|
||||
|
||||
LOG = logging.getLogger("bridge")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
cfg: dict[str, Any]
|
||||
prefix: str
|
||||
clients: list[SimpleUDPClient]
|
||||
feed_name: str
|
||||
|
||||
def send(self, sub: str, *args: Any) -> None:
|
||||
path = f"{self.prefix}/{self.feed_name}/{sub}"
|
||||
for c in self.clients:
|
||||
try:
|
||||
c.send_message(path, list(args))
|
||||
except OSError as e:
|
||||
LOG.warning("OSC send failed %s: %s", path, e)
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict[str, Any]:
|
||||
with path.open("rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
async def run_feed(name: str, cfg: dict[str, Any], ctx: Context) -> None:
|
||||
"""Charge `data_feeds.feeds.<name>` et appelle `run(ctx)`."""
|
||||
try:
|
||||
mod = importlib.import_module(f"data_feeds.feeds.{name}")
|
||||
except ModuleNotFoundError:
|
||||
# Fallback : exécution depuis le dossier data_feeds/
|
||||
mod = importlib.import_module(f"feeds.{name}")
|
||||
LOG.info("starting feed: %s", name)
|
||||
backoff = 1.0
|
||||
while True:
|
||||
try:
|
||||
await mod.run(ctx)
|
||||
# Si run() retourne sans exception, on redémarre poliment
|
||||
await asyncio.sleep(2.0)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.error("feed %s crashed: %s — retry in %.1fs", name, e, backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60.0)
|
||||
|
||||
|
||||
async def heartbeat(clients: list[SimpleUDPClient], prefix: str) -> None:
|
||||
t0 = time.monotonic()
|
||||
while True:
|
||||
for c in clients:
|
||||
c.send_message(f"{prefix}/heartbeat", [time.monotonic() - t0])
|
||||
await asyncio.sleep(5.0)
|
||||
|
||||
|
||||
async def main_async(cfg: dict[str, Any]) -> None:
|
||||
osc_cfg = cfg.get("osc", {})
|
||||
prefix = osc_cfg.get("prefix", "/data")
|
||||
clients = [
|
||||
SimpleUDPClient(t["host"], t["port"])
|
||||
for t in osc_cfg.get("targets", [{"host": "127.0.0.1", "port": 57121}])
|
||||
]
|
||||
LOG.info(
|
||||
"OSC targets: %s",
|
||||
", ".join(f"{c._address}:{c._port}" for c in clients), # noqa: SLF001
|
||||
)
|
||||
|
||||
tasks: list[asyncio.Task[None]] = []
|
||||
for name, fcfg in cfg.get("feeds", {}).items():
|
||||
if not fcfg.get("enabled", False):
|
||||
LOG.info("feed disabled: %s", name)
|
||||
continue
|
||||
ctx = Context(cfg=fcfg, prefix=prefix, clients=clients, feed_name=name)
|
||||
tasks.append(asyncio.create_task(run_feed(name, fcfg, ctx), name=name))
|
||||
|
||||
if not tasks:
|
||||
LOG.warning("no feed enabled — exiting")
|
||||
return
|
||||
|
||||
tasks.append(asyncio.create_task(heartbeat(clients, prefix), name="heartbeat"))
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
stop = loop.create_future()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, lambda: stop.cancel() if not stop.done() else None)
|
||||
try:
|
||||
await stop
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
LOG.info("bridge stopped")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("-c", "--config", type=Path, default=Path(__file__).parent / "config.toml")
|
||||
p.add_argument("-v", "--verbose", action="store_true")
|
||||
args = p.parse_args()
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s — %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
cfg = load_config(args.config)
|
||||
try:
|
||||
asyncio.run(main_async(cfg))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,108 @@
|
||||
# Configuration du pont data_feeds → OSC.
|
||||
#
|
||||
# - SC écoute par défaut sur 57121 (cf. sound_algo/web_bridge.scd).
|
||||
# - oF écoute sur 57123 (cf. ofApp::setup, oscListenPort_).
|
||||
# Le pont diffuse en broadcast vers TOUS les `osc_targets` listés.
|
||||
#
|
||||
# Activer/désactiver un flux : `enabled = true|false`.
|
||||
# Régler le débit avec `poll_seconds` (HTTP) ou laisser les WS gérer.
|
||||
|
||||
[osc]
|
||||
targets = [
|
||||
{ host = "127.0.0.1", port = 57121 }, # SuperCollider
|
||||
{ host = "127.0.0.1", port = 57123 }, # openFrameworks
|
||||
]
|
||||
# Préfixe commun. Toutes les routes sont /data/<feed>/...
|
||||
prefix = "/data"
|
||||
|
||||
# -- Sismique / géophysique ------------------------------------------------
|
||||
|
||||
[feeds.usgs]
|
||||
enabled = true
|
||||
url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson"
|
||||
poll_seconds = 60
|
||||
# Émet /data/usgs/event <mag> <lon> <lat> <depth> <age_sec>
|
||||
# /data/usgs/rate <events_per_hour>
|
||||
|
||||
[feeds.swpc]
|
||||
enabled = true
|
||||
# Vent solaire (DSCOVR plasma)
|
||||
url_plasma = "https://services.swpc.noaa.gov/products/solar-wind/plasma-1-day.json"
|
||||
url_mag = "https://services.swpc.noaa.gov/products/solar-wind/mag-1-day.json"
|
||||
url_kp = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json"
|
||||
url_xray = "https://services.swpc.noaa.gov/json/goes/primary/xrays-1-day.json"
|
||||
poll_seconds = 60
|
||||
# /data/swpc/wind <speed_kms> <density_pcm3> <temp_K>
|
||||
# /data/swpc/bz <Bz_nT> <Bt_nT>
|
||||
# /data/swpc/kp <kp> <a_index>
|
||||
# /data/swpc/xray <short_W_m2> <long_W_m2> <flare_class_norm>
|
||||
|
||||
# -- Réseau électrique -----------------------------------------------------
|
||||
|
||||
[feeds.netzfrequenz]
|
||||
enabled = true
|
||||
# Mainsfrequenz.de WebSocket (résolution ~200 ms, mesure Karlsruhe)
|
||||
ws_url = "wss://www.mainsfrequenz.de/frequenz.socket"
|
||||
# /data/grid/freq <hz> 50.000 ± 0.200
|
||||
# /data/grid/dev <delta_hz> écart vs 50 Hz
|
||||
# /data/grid/time_dev <sec> dérive intégrée
|
||||
|
||||
[feeds.rte_eco2mix]
|
||||
enabled = false # nécessite token OAuth RTE (gratuit, register)
|
||||
client_id = ""
|
||||
client_secret = ""
|
||||
poll_seconds = 900
|
||||
# /data/rte/mix <nuclear> <gas> <coal> <oil> <hydro> <wind> <solar> <bio>
|
||||
# /data/rte/co2 <gCO2_per_kWh>
|
||||
|
||||
# -- Foudre / atmosphère ---------------------------------------------------
|
||||
|
||||
[feeds.blitzortung]
|
||||
enabled = true
|
||||
# LightningMaps relay (Blitzortung dérivé, public)
|
||||
ws_url = "wss://ws1.blitzortung.org:443/"
|
||||
# /data/lightning/strike <lat> <lon> <age_sec> <multiplicity>
|
||||
# /data/lightning/rate <strikes_per_min>
|
||||
|
||||
# -- Aviation / mouvement --------------------------------------------------
|
||||
|
||||
[feeds.opensky]
|
||||
enabled = true
|
||||
url = "https://opensky-network.org/api/states/all"
|
||||
poll_seconds = 15
|
||||
# Bbox optionnelle (Lyon par défaut : lamin,lomin,lamax,lomax)
|
||||
bbox = [45.5, 4.6, 46.0, 5.2]
|
||||
# /data/aviation/count <n>
|
||||
# /data/aviation/plane <icao> <lon> <lat> <alt_m> <vel_ms> <heading_deg>
|
||||
|
||||
# -- Pouls numérique -------------------------------------------------------
|
||||
|
||||
[feeds.bluesky]
|
||||
enabled = true
|
||||
# Jetstream firehose (posts publics WS, JSON décompressé)
|
||||
ws_url = "wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post"
|
||||
sample_rate = 0.02 # garde 2 % des événements pour pas saturer
|
||||
# /data/social/post <text_len> <lang_hash>
|
||||
# /data/social/rate <posts_per_sec>
|
||||
|
||||
[feeds.mempool]
|
||||
enabled = false
|
||||
ws_url = "wss://mempool.space/api/v1/ws"
|
||||
# /data/btc/tx <value_btc> <fee_sat_vb>
|
||||
# /data/btc/block <height> <tx_count> <reward_btc>
|
||||
|
||||
[feeds.github]
|
||||
enabled = false
|
||||
url = "https://api.github.com/events"
|
||||
poll_seconds = 30
|
||||
# /data/github/event <type_hash> <repo_hash>
|
||||
|
||||
# -- Espace / GCN ----------------------------------------------------------
|
||||
|
||||
[feeds.gcn]
|
||||
enabled = false
|
||||
# GCN Classic over Kafka : nécessite credentials.
|
||||
# Voir https://gcn.nasa.gov/quickstart pour générer un token.
|
||||
client_id = ""
|
||||
client_secret = ""
|
||||
# /data/gcn/alert <mission_hash> <ra_deg> <dec_deg> <error_arcmin>
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Helpers communs aux feeds."""
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class RateMeter:
|
||||
"""Compte les événements sur une fenêtre glissante (en secondes)."""
|
||||
|
||||
def __init__(self, window: float = 60.0) -> None:
|
||||
self.window = window
|
||||
self._events: collections.deque[float] = collections.deque()
|
||||
|
||||
def tick(self) -> int:
|
||||
now = time.monotonic()
|
||||
self._events.append(now)
|
||||
while self._events and now - self._events[0] > self.window:
|
||||
self._events.popleft()
|
||||
return len(self._events)
|
||||
|
||||
@property
|
||||
def rate(self) -> float:
|
||||
return len(self._events) / max(self.window, 1e-6)
|
||||
|
||||
|
||||
def djb2(s: str) -> int:
|
||||
"""Hash stable 0..65535 pour transformer une string en float OSC."""
|
||||
h = 5381
|
||||
for c in s.encode("utf-8", errors="ignore"):
|
||||
h = ((h << 5) + h + c) & 0xFFFF
|
||||
return h
|
||||
|
||||
|
||||
def fnorm(x: float, lo: float, hi: float) -> float:
|
||||
if hi <= lo:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, (x - lo) / (hi - lo)))
|
||||
|
||||
|
||||
def safe_get(d: dict, path: Iterable[str], default=None):
|
||||
cur = d
|
||||
for k in path:
|
||||
if isinstance(cur, dict) and k in cur:
|
||||
cur = cur[k]
|
||||
else:
|
||||
return default
|
||||
return cur
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Blitzortung / LightningMaps — impacts de foudre temps réel.
|
||||
|
||||
Protocole : à la connexion, envoyer `{"a":111}` (handshake LightningMaps).
|
||||
Chaque message JSON contient { time, lat, lon, mds (multiplicity)... }.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
from ._util import RateMeter
|
||||
|
||||
LOG = logging.getLogger("feed.blitzortung")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["ws_url"]
|
||||
rate = RateMeter(window=60.0)
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=20, max_size=2**20) as ws:
|
||||
await ws.send(json.dumps({"a": 111}))
|
||||
LOG.info("connected %s", url)
|
||||
async for raw in ws:
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
lat = float(d.get("lat", 0.0))
|
||||
lon = float(d.get("lon", 0.0))
|
||||
# time est en ns Unix ; on calcule un age en secondes
|
||||
t_ns = d.get("time", 0)
|
||||
age = 0.0
|
||||
if isinstance(t_ns, (int, float)) and t_ns > 0:
|
||||
age = max(0.0, time.time() - t_ns / 1e9)
|
||||
mult = int(d.get("mds") or 1)
|
||||
ctx.send("strike", lat, lon, age, mult)
|
||||
rate.tick()
|
||||
if rate._events: # noqa: SLF001
|
||||
ctx.send("rate", rate.rate * 60.0)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("ws disconnected: %s — reconnecting", e)
|
||||
await asyncio.sleep(5.0)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Bluesky Jetstream — firehose des posts publics (WebSocket JSON)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
from ._util import RateMeter, djb2
|
||||
|
||||
LOG = logging.getLogger("feed.bluesky")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["ws_url"]
|
||||
sample = float(cfg.get("sample_rate", 0.02))
|
||||
rate = RateMeter(window=10.0)
|
||||
last_rate_emit = 0.0
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=20, max_size=2**20) as ws:
|
||||
LOG.info("connected jetstream (sample %.0f%%)", sample * 100)
|
||||
async for raw in ws:
|
||||
rate.tick()
|
||||
now = time.monotonic()
|
||||
if now - last_rate_emit > 1.0:
|
||||
ctx.send("rate", rate.rate)
|
||||
last_rate_emit = now
|
||||
if random.random() > sample:
|
||||
continue
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
commit = d.get("commit") or {}
|
||||
rec = commit.get("record") or {}
|
||||
text = rec.get("text") or ""
|
||||
lang = (rec.get("langs") or ["?"])[0]
|
||||
ctx.send("post", float(len(text)), float(djb2(lang)))
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("ws disconnected: %s — reconnecting", e)
|
||||
await asyncio.sleep(3.0)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""GCN Classic over Kafka — alertes astrophysiques (GRB, GW, neutrinos).
|
||||
|
||||
Nécessite des credentials Kafka. Voir https://gcn.nasa.gov/quickstart.
|
||||
Cette implémentation est volontairement minimale : on extrait ra/dec/error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
LOG = logging.getLogger("feed.gcn")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
cid, csec = cfg.get("client_id"), cfg.get("client_secret")
|
||||
if not (cid and csec):
|
||||
LOG.warning("GCN credentials manquants — feed inactif (voir gcn.nasa.gov/quickstart)")
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
try:
|
||||
from gcn_kafka import Consumer # type: ignore
|
||||
except ModuleNotFoundError:
|
||||
LOG.error("`gcn-kafka` non installé. uv add gcn-kafka")
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
|
||||
from ._util import djb2
|
||||
|
||||
cons = Consumer(client_id=cid, client_secret=csec)
|
||||
cons.subscribe([
|
||||
"gcn.classic.text.SWIFT_BAT_GRB_POS_ACK",
|
||||
"gcn.classic.text.FERMI_GBM_FLT_POS",
|
||||
"gcn.classic.text.LVC_INITIAL",
|
||||
"gcn.classic.text.ICECUBE_ASTROTRACK_GOLD",
|
||||
])
|
||||
LOG.info("subscribed GCN classic streams")
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _poll():
|
||||
return cons.consume(num_messages=10, timeout=1.0)
|
||||
|
||||
while True:
|
||||
msgs = await loop.run_in_executor(None, _poll)
|
||||
for m in msgs or []:
|
||||
if m.error():
|
||||
continue
|
||||
txt = m.value().decode("utf-8", "ignore")
|
||||
ra, dec, err = _parse(txt)
|
||||
ctx.send("alert", float(djb2(m.topic())), ra, dec, err)
|
||||
|
||||
|
||||
def _parse(txt: str) -> tuple[float, float, float]:
|
||||
ra = dec = err = 0.0
|
||||
for line in txt.splitlines():
|
||||
l = line.lower()
|
||||
try:
|
||||
if "ra:" in l and ra == 0.0:
|
||||
ra = float(line.split(":", 1)[1].split()[0])
|
||||
elif "dec:" in l and dec == 0.0:
|
||||
dec = float(line.split(":", 1)[1].split()[0])
|
||||
elif "error" in l and "arcmin" in l and err == 0.0:
|
||||
err = float(line.split(":", 1)[1].split()[0])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
return ra, dec, err
|
||||
@@ -0,0 +1,37 @@
|
||||
"""GitHub public events — firehose dev mondial (polling REST anonyme)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from ._util import djb2
|
||||
|
||||
LOG = logging.getLogger("feed.github")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["url"]
|
||||
period = float(cfg.get("poll_seconds", 30))
|
||||
last_id = ""
|
||||
async with httpx.AsyncClient(timeout=20.0,
|
||||
headers={"Accept": "application/vnd.github+json"}) as cli:
|
||||
while True:
|
||||
try:
|
||||
r = await cli.get(url)
|
||||
r.raise_for_status()
|
||||
for ev in reversed(r.json()):
|
||||
eid = ev.get("id", "")
|
||||
if eid <= last_id:
|
||||
continue
|
||||
ctx.send(
|
||||
"event",
|
||||
float(djb2(ev.get("type", "?"))),
|
||||
float(djb2(((ev.get("repo") or {}).get("name") or "?"))),
|
||||
)
|
||||
last_id = eid
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("fetch failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""mempool.space — Bitcoin txs and blocks (WebSocket)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import websockets
|
||||
|
||||
LOG = logging.getLogger("feed.mempool")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["ws_url"]
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=20, max_size=2**21) as ws:
|
||||
await ws.send(json.dumps({"action": "want", "data": ["mempool-blocks", "blocks", "live-2h-chart"]}))
|
||||
LOG.info("connected mempool.space")
|
||||
async for raw in ws:
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if "block" in d:
|
||||
b = d["block"] or {}
|
||||
ctx.send(
|
||||
"block",
|
||||
float(b.get("height", 0)),
|
||||
float(b.get("tx_count", 0)),
|
||||
float(b.get("extras", {}).get("reward", 0) or 0) / 1e8,
|
||||
)
|
||||
if "transactions" in d:
|
||||
for tx in (d["transactions"] or [])[:5]:
|
||||
ctx.send(
|
||||
"tx",
|
||||
float(tx.get("value", 0)) / 1e8,
|
||||
float(tx.get("fee", 0)) / max(1.0, float(tx.get("vsize", 1))),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("ws disconnected: %s — reconnecting", e)
|
||||
await asyncio.sleep(5.0)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Fréquence du réseau électrique européen — WebSocket Mainsfrequenz.de.
|
||||
|
||||
Format payload (texte) : "f=49.987 t=2026-05-11T06:42:00Z" environ.
|
||||
Le serveur peut changer ; on parse defensively et on extrait `f`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
LOG = logging.getLogger("feed.netzfrequenz")
|
||||
|
||||
_RE_F = re.compile(r"f\s*=\s*([0-9]+\.[0-9]+)")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["ws_url"]
|
||||
time_dev = 0.0 # dérive intégrée (secondes)
|
||||
last_t = time.monotonic()
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=20) as ws:
|
||||
LOG.info("connected %s", url)
|
||||
async for msg in ws:
|
||||
text = msg if isinstance(msg, str) else msg.decode("utf-8", "ignore")
|
||||
m = _RE_F.search(text)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
f = float(m.group(1))
|
||||
except ValueError:
|
||||
continue
|
||||
now = time.monotonic()
|
||||
dt = now - last_t
|
||||
last_t = now
|
||||
delta = f - 50.0
|
||||
# Intégration : 1 s réelle à 49.5 Hz → -0.01 s d'horloge
|
||||
time_dev += (delta / 50.0) * dt
|
||||
ctx.send("freq", f)
|
||||
ctx.send("dev", delta)
|
||||
ctx.send("time_dev", time_dev)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("ws disconnected: %s — reconnecting", e)
|
||||
await asyncio.sleep(3.0)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""OpenSky Network — ADS-B aircraft states (REST polling, anon ≤ 15 s)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
LOG = logging.getLogger("feed.opensky")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
base = cfg["url"]
|
||||
period = float(cfg.get("poll_seconds", 15))
|
||||
bbox = cfg.get("bbox") # [lamin, lomin, lamax, lomax]
|
||||
params = None
|
||||
if bbox and len(bbox) == 4:
|
||||
params = {
|
||||
"lamin": bbox[0], "lomin": bbox[1],
|
||||
"lamax": bbox[2], "lomax": bbox[3],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=20.0) as cli:
|
||||
while True:
|
||||
try:
|
||||
r = await cli.get(base, params=params)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("fetch failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
continue
|
||||
states = data.get("states") or []
|
||||
ctx.send("count", float(len(states)))
|
||||
for s in states:
|
||||
# index: 0=icao24, 5=lon, 6=lat, 7=baro_alt, 9=velocity, 10=heading
|
||||
try:
|
||||
icao = (s[0] or "?")[:8]
|
||||
lon = float(s[5]) if s[5] is not None else 0.0
|
||||
lat = float(s[6]) if s[6] is not None else 0.0
|
||||
alt = float(s[7]) if s[7] is not None else 0.0
|
||||
vel = float(s[9]) if s[9] is not None else 0.0
|
||||
head = float(s[10]) if s[10] is not None else 0.0
|
||||
except (IndexError, TypeError, ValueError):
|
||||
continue
|
||||
ctx.send("plane", icao, lon, lat, alt, vel, head)
|
||||
await asyncio.sleep(period)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""RTE éCO2mix — mix électrique France (OAuth2 client_credentials)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
LOG = logging.getLogger("feed.rte_eco2mix")
|
||||
|
||||
TOKEN_URL = "https://digital.iservices.rte-france.com/token/oauth/"
|
||||
API_URL = "https://digital.iservices.rte-france.com/open_api/actual_generation/v1/actual_generations_per_production_type"
|
||||
|
||||
|
||||
async def _get_token(cli: httpx.AsyncClient, cid: str, csec: str) -> tuple[str, float]:
|
||||
r = await cli.post(TOKEN_URL, auth=(cid, csec),
|
||||
data={"grant_type": "client_credentials"})
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
return j["access_token"], time.monotonic() + float(j.get("expires_in", 7200)) - 60
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
cid, csec = cfg.get("client_id"), cfg.get("client_secret")
|
||||
period = float(cfg.get("poll_seconds", 900))
|
||||
if not (cid and csec):
|
||||
LOG.warning("client_id/client_secret manquants — feed inactif")
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
token, exp = "", 0.0
|
||||
async with httpx.AsyncClient(timeout=30.0) as cli:
|
||||
while True:
|
||||
try:
|
||||
if time.monotonic() > exp:
|
||||
token, exp = await _get_token(cli, cid, csec)
|
||||
r = await cli.get(API_URL, headers={"Authorization": f"Bearer {token}"})
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
latest = {}
|
||||
for series in (j.get("actual_generations_per_production_type") or []):
|
||||
typ = series.get("production_type", "?")
|
||||
vals = series.get("values") or []
|
||||
if vals:
|
||||
latest[typ] = float(vals[-1].get("value", 0.0))
|
||||
# mapping standard RTE → ordre des args
|
||||
ctx.send(
|
||||
"mix",
|
||||
latest.get("NUCLEAR", 0.0),
|
||||
latest.get("GAS", 0.0),
|
||||
latest.get("COAL", 0.0),
|
||||
latest.get("OIL", 0.0),
|
||||
latest.get("HYDRO", 0.0),
|
||||
latest.get("WIND", 0.0),
|
||||
latest.get("SOLAR", 0.0),
|
||||
latest.get("BIOENERGY", 0.0),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("fetch failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""NOAA SWPC — vent solaire, IMF Bz, indice Kp, X-ray flux GOES."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
|
||||
import httpx
|
||||
|
||||
LOG = logging.getLogger("feed.swpc")
|
||||
|
||||
|
||||
def _last_row(data) -> list | None:
|
||||
if not data or len(data) < 2:
|
||||
return None
|
||||
return data[-1]
|
||||
|
||||
|
||||
def _flare_class_norm(long_wm2: float) -> float:
|
||||
"""Mappe le X-ray long band en classe normalisée 0..1.
|
||||
A=1e-8, B=1e-7, C=1e-6, M=1e-5, X=1e-4. log10 → [0..1] sur A→X.
|
||||
"""
|
||||
if long_wm2 <= 0:
|
||||
return 0.0
|
||||
return max(0.0, min(1.0, (math.log10(long_wm2) + 8.0) / 4.0))
|
||||
|
||||
|
||||
async def _fetch_json(cli: httpx.AsyncClient, url: str):
|
||||
r = await cli.get(url)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
period = float(cfg.get("poll_seconds", 60))
|
||||
urls = {
|
||||
"plasma": cfg.get("url_plasma"),
|
||||
"mag": cfg.get("url_mag"),
|
||||
"kp": cfg.get("url_kp"),
|
||||
"xray": cfg.get("url_xray"),
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=20.0) as cli:
|
||||
while True:
|
||||
try:
|
||||
if urls["plasma"]:
|
||||
j = await _fetch_json(cli, urls["plasma"])
|
||||
row = _last_row(j)
|
||||
if row:
|
||||
# ["time_tag","density","speed","temperature"]
|
||||
try:
|
||||
density = float(row[1])
|
||||
speed = float(row[2])
|
||||
temp = float(row[3])
|
||||
ctx.send("wind", speed, density, temp)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if urls["mag"]:
|
||||
j = await _fetch_json(cli, urls["mag"])
|
||||
row = _last_row(j)
|
||||
if row:
|
||||
# ["time_tag","bx_gsm","by_gsm","bz_gsm","lon_gsm","lat_gsm","bt"]
|
||||
try:
|
||||
bz = float(row[3])
|
||||
bt = float(row[6])
|
||||
ctx.send("bz", bz, bt)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if urls["kp"]:
|
||||
j = await _fetch_json(cli, urls["kp"])
|
||||
row = _last_row(j)
|
||||
if row:
|
||||
# ["time_tag","Kp","a_running","station_count"]
|
||||
try:
|
||||
kp = float(row[1])
|
||||
a = float(row[2])
|
||||
ctx.send("kp", kp, a)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if urls["xray"]:
|
||||
j = await _fetch_json(cli, urls["xray"])
|
||||
# split short/long bands
|
||||
short = next((d for d in reversed(j) if d.get("energy") == "0.05-0.4nm"), None)
|
||||
long_ = next((d for d in reversed(j) if d.get("energy") == "0.1-0.8nm"), None)
|
||||
s = float(short.get("flux", 0.0)) if short else 0.0
|
||||
l = float(long_.get("flux", 0.0)) if long_ else 0.0
|
||||
ctx.send("xray", s, l, _flare_class_norm(l))
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("fetch failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""USGS earthquakes — GeoJSON polling (1 min)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from ._util import RateMeter
|
||||
|
||||
LOG = logging.getLogger("feed.usgs")
|
||||
|
||||
|
||||
async def run(ctx) -> None:
|
||||
cfg = ctx.cfg
|
||||
url = cfg["url"]
|
||||
period = float(cfg.get("poll_seconds", 60))
|
||||
seen: set[str] = set()
|
||||
rate = RateMeter(window=3600.0)
|
||||
async with httpx.AsyncClient(timeout=20.0) as cli:
|
||||
while True:
|
||||
try:
|
||||
r = await cli.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("fetch failed: %s", e)
|
||||
await asyncio.sleep(period)
|
||||
continue
|
||||
|
||||
now_ms = time.time() * 1000.0
|
||||
for feat in data.get("features", []):
|
||||
fid = feat.get("id")
|
||||
if not fid or fid in seen:
|
||||
continue
|
||||
seen.add(fid)
|
||||
props = feat.get("properties") or {}
|
||||
coords = (feat.get("geometry") or {}).get("coordinates") or [0, 0, 0]
|
||||
mag = float(props.get("mag") or 0.0)
|
||||
t_ms = float(props.get("time") or now_ms)
|
||||
age = max(0.0, (now_ms - t_ms) / 1000.0)
|
||||
ctx.send("event", mag, float(coords[0]), float(coords[1]),
|
||||
float(coords[2]), age)
|
||||
rate.tick()
|
||||
ctx.send("rate", rate.rate * 3600.0)
|
||||
|
||||
# garde la mémoire bornée
|
||||
if len(seen) > 4096:
|
||||
seen = set(list(seen)[-2048:])
|
||||
await asyncio.sleep(period)
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "av-live-data-feeds"
|
||||
version = "0.1.0"
|
||||
description = "Real-world data → OSC bridge for AV-Live (SuperCollider + openFrameworks)"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"python-osc>=1.8.3",
|
||||
"httpx>=0.27",
|
||||
"websockets>=12.0",
|
||||
"aiomqtt>=2.3",
|
||||
"tomli>=2.0;python_version<'3.11'",
|
||||
"skyfield>=1.49",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
@@ -1,9 +1,15 @@
|
||||
#include "OscClient.h"
|
||||
|
||||
#include "ofLog.h"
|
||||
#include "ofUtils.h"
|
||||
|
||||
namespace oscope {
|
||||
|
||||
namespace {
|
||||
constexpr const char* kDataPrefix = "/data/";
|
||||
}
|
||||
|
||||
|
||||
void OscClient::setup(int listenPort, const std::string& sendHost, int sendPort) {
|
||||
receiver_.setup(listenPort);
|
||||
sender_.setup(sendHost, sendPort);
|
||||
@@ -42,10 +48,73 @@ void OscClient::update() {
|
||||
} else if (a == "/oscope/glitch" && m.getNumArgs() >= 1) {
|
||||
pendingGlitchPulse_ = m.getArgAsFloat(0);
|
||||
hasGlitchPulse_ = true;
|
||||
} else if (a.rfind(kDataPrefix, 0) == 0) {
|
||||
// /data/heartbeat ou /data/<source>/<sub>
|
||||
if (a == "/data/heartbeat") {
|
||||
lastHeartbeat_ = ofGetElapsedTimef();
|
||||
} else {
|
||||
storeData(a, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OscClient::storeData(const std::string& addr, const ofxOscMessage& m) {
|
||||
// strip "/data/" prefix → key = "source/sub"
|
||||
std::string key = addr.substr(6);
|
||||
if (key.empty()) return;
|
||||
auto& slot = data_[key];
|
||||
slot.last.clear();
|
||||
slot.last.reserve(m.getNumArgs());
|
||||
for (std::size_t i = 0; i < (std::size_t)m.getNumArgs(); ++i) {
|
||||
// Les flux poussent surtout des floats ; on tente string→hash
|
||||
// pour rester homogene. Les ints sont remontes en float aussi.
|
||||
auto t = m.getArgType(i);
|
||||
if (t == OFXOSC_TYPE_FLOAT) {
|
||||
slot.last.push_back(m.getArgAsFloat(i));
|
||||
} else if (t == OFXOSC_TYPE_INT32) {
|
||||
slot.last.push_back((float)m.getArgAsInt32(i));
|
||||
} else if (t == OFXOSC_TYPE_DOUBLE) {
|
||||
slot.last.push_back((float)m.getArgAsDouble(i));
|
||||
} else if (t == OFXOSC_TYPE_STRING) {
|
||||
// hash djb2 16 bits pour cohérence avec le pont Python
|
||||
const auto& s = m.getArgAsString(i);
|
||||
std::uint32_t h = 5381;
|
||||
for (char c : s) h = ((h << 5) + h + (unsigned char)c) & 0xFFFF;
|
||||
slot.last.push_back((float)h);
|
||||
}
|
||||
}
|
||||
slot.pending = true;
|
||||
}
|
||||
|
||||
const std::vector<float>& OscClient::data(const std::string& source,
|
||||
const std::string& sub) const {
|
||||
static const std::vector<float> empty;
|
||||
auto it = data_.find(source + "/" + sub);
|
||||
return (it == data_.end()) ? empty : it->second.last;
|
||||
}
|
||||
|
||||
float OscClient::dataf(const std::string& source, const std::string& sub,
|
||||
float fallback, std::size_t index) const {
|
||||
const auto& v = data(source, sub);
|
||||
return (index < v.size()) ? v[index] : fallback;
|
||||
}
|
||||
|
||||
bool OscClient::dataAlive() const {
|
||||
return lastHeartbeat_ >= 0.0 &&
|
||||
(ofGetElapsedTimef() - lastHeartbeat_) < 15.0f;
|
||||
}
|
||||
|
||||
bool OscClient::consumeDataPulse(const std::string& source,
|
||||
const std::string& sub,
|
||||
std::vector<float>& outArgs) {
|
||||
auto it = data_.find(source + "/" + sub);
|
||||
if (it == data_.end() || !it->second.pending) return false;
|
||||
outArgs = it->second.last;
|
||||
it->second.pending = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
float OscClient::fx(const std::string& name, float fallback) const {
|
||||
auto it = fx_.find(name);
|
||||
return (it == fx_.end()) ? fallback : it->second;
|
||||
|
||||
@@ -16,10 +16,17 @@
|
||||
|
||||
#include "ofxOsc.h"
|
||||
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
// En complement, ce client recoit aussi les flux /data/<source>/<sub>
|
||||
// emis par data_feeds/bridge.py. Les arguments numeriques de chaque
|
||||
// message sont conserves dans un vector accessible via data(source, sub).
|
||||
// Le dernier evenement est aussi disponible comme "pulse" consommable
|
||||
// (events de foudre, transactions BTC, posts Bluesky, etc.).
|
||||
|
||||
namespace oscope {
|
||||
|
||||
class OscClient {
|
||||
@@ -52,7 +59,30 @@ public:
|
||||
void sendControl(const std::string& addr, float value);
|
||||
void sendControl(const std::string& addr, const std::string& value);
|
||||
|
||||
/// ----- Flux temps reel externes (data_feeds bridge) -----
|
||||
/// Acces direct au dernier tuple recu sur /data/<source>/<sub>.
|
||||
/// Renvoie vide si rien n'a encore ete recu.
|
||||
const std::vector<float>& data(const std::string& source,
|
||||
const std::string& sub) const;
|
||||
/// Helper : premier arg float du dernier tuple, avec fallback.
|
||||
float dataf(const std::string& source, const std::string& sub,
|
||||
float fallback = 0.0f, std::size_t index = 0) const;
|
||||
/// Heartbeat du pont Python (true si recu il y a < 15 s).
|
||||
bool dataAlive() const;
|
||||
|
||||
/// Pulse pour les flux event-based : retourne true UNE fois si un
|
||||
/// nouvel evenement /data/<source>/<sub> est arrive depuis le
|
||||
/// dernier appel, et remplit `outArgs` avec ses arguments float.
|
||||
bool consumeDataPulse(const std::string& source, const std::string& sub,
|
||||
std::vector<float>& outArgs);
|
||||
|
||||
private:
|
||||
struct DataSlot {
|
||||
std::vector<float> last;
|
||||
bool pending = false;
|
||||
};
|
||||
DataSlot* dataSlot(const std::string& key);
|
||||
void storeData(const std::string& addr, const ofxOscMessage& m);
|
||||
ofxOscReceiver receiver_;
|
||||
ofxOscSender sender_;
|
||||
|
||||
@@ -67,6 +97,10 @@ private:
|
||||
std::string album_;
|
||||
std::string melody_;
|
||||
std::string synthdef_;
|
||||
|
||||
// Flux /data/<source>/<sub>. Cle = "<source>/<sub>".
|
||||
std::unordered_map<std::string, DataSlot> data_;
|
||||
double lastHeartbeat_ = -1.0;
|
||||
};
|
||||
|
||||
} // namespace oscope
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
// =====================================================================
|
||||
// data_feeds.scd -- Reception OSC des flux temps reel externes.
|
||||
//
|
||||
// Lance le pont : cd data_feeds && uv run python bridge.py
|
||||
//
|
||||
// Architecture :
|
||||
// Python (USGS, SWPC, Netzfrequenz, Blitzortung, OpenSky, Bluesky, ...)
|
||||
// \--OSC--> 57121 (sclang) ET 57123 (oF)
|
||||
//
|
||||
// Toutes les valeurs sont stockees dans ~feeds (IdentityDictionary).
|
||||
// Lire : ~feeds[\netz_freq] ?? 50.0
|
||||
// Mapper:~feeds[\netz_dev].linlin(-0.2, 0.2, -100, 100)
|
||||
//
|
||||
// Les definitions sont idempotentes : on peut relire le fichier sans
|
||||
// accumuler des OSCdef fantomes.
|
||||
// =====================================================================
|
||||
(
|
||||
~feeds = ~feeds ? IdentityDictionary.new;
|
||||
~feeds[\__t0] = SystemClock.seconds;
|
||||
|
||||
// ---- helper de pose : ferme + recree -----------------------------
|
||||
~_feedDef = { |key, path, fn|
|
||||
OSCdef(key).free;
|
||||
OSCdef(key, fn, path);
|
||||
};
|
||||
|
||||
// ---- bus de notification : permet aux abonnes (synths, patterns,
|
||||
// visus) de reagir a un changement specifique.
|
||||
// ~feedSub.(\netz_freq, { |val| ... }) renvoie une cle de retrait.
|
||||
~feedListeners = ~feedListeners ? IdentityDictionary.new;
|
||||
~feedSub = { |key, action|
|
||||
var arr = ~feedListeners[key] ? Array.new;
|
||||
~feedListeners[key] = arr.add(action);
|
||||
action;
|
||||
};
|
||||
~feedUnsub = { |key, action|
|
||||
var arr = ~feedListeners[key];
|
||||
if(arr.notNil) { ~feedListeners[key] = arr.reject(_ == action) };
|
||||
};
|
||||
~_feedNotify = { |key, val|
|
||||
(~feedListeners[key] ? #[]).do { |fn| fn.value(val) };
|
||||
};
|
||||
~feedSet = { |key, val| ~feeds[key] = val; ~_feedNotify.(key, val) };
|
||||
|
||||
// =====================================================================
|
||||
// USGS -- seismicite
|
||||
// =====================================================================
|
||||
// /data/usgs/event <mag> <lon> <lat> <depth> <age_sec>
|
||||
~_feedDef.(\d_usgs_event, '/data/usgs/event', { |msg|
|
||||
var mag = msg[1], lon = msg[2], lat = msg[3], depth = msg[4], age = msg[5];
|
||||
~feedSet.(\usgs_last_mag, mag);
|
||||
~feedSet.(\usgs_last_pos, [lon, lat]);
|
||||
~feedSet.(\usgs_last_age, age);
|
||||
~feedSet.(\usgs_last_depth, depth);
|
||||
});
|
||||
~_feedDef.(\d_usgs_rate, '/data/usgs/rate', { |msg|
|
||||
~feedSet.(\usgs_rate_h, msg[1]);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// SWPC -- vent solaire, IMF, Kp, X-ray
|
||||
// =====================================================================
|
||||
// /data/swpc/wind <speed_kms> <density_pcm3> <temp_K>
|
||||
~_feedDef.(\d_swpc_wind, '/data/swpc/wind', { |msg|
|
||||
~feedSet.(\swpc_wind_speed, msg[1]);
|
||||
~feedSet.(\swpc_wind_dens, msg[2]);
|
||||
~feedSet.(\swpc_wind_temp, msg[3]);
|
||||
});
|
||||
~_feedDef.(\d_swpc_bz, '/data/swpc/bz', { |msg|
|
||||
~feedSet.(\swpc_bz, msg[1]);
|
||||
~feedSet.(\swpc_bt, msg[2]);
|
||||
});
|
||||
~_feedDef.(\d_swpc_kp, '/data/swpc/kp', { |msg|
|
||||
~feedSet.(\swpc_kp, msg[1]);
|
||||
~feedSet.(\swpc_a, msg[2]);
|
||||
});
|
||||
~_feedDef.(\d_swpc_xray, '/data/swpc/xray', { |msg|
|
||||
~feedSet.(\swpc_xray_short, msg[1]);
|
||||
~feedSet.(\swpc_xray_long, msg[2]);
|
||||
~feedSet.(\swpc_flare_norm, msg[3]); // 0..1 (A->X)
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// GRID -- frequence du reseau 50 Hz
|
||||
// =====================================================================
|
||||
// /data/netzfrequenz/freq <hz>
|
||||
~_feedDef.(\d_grid_freq, '/data/netzfrequenz/freq', { |msg|
|
||||
~feedSet.(\netz_freq, msg[1]);
|
||||
});
|
||||
~_feedDef.(\d_grid_dev, '/data/netzfrequenz/dev', { |msg|
|
||||
~feedSet.(\netz_dev, msg[1]);
|
||||
});
|
||||
~_feedDef.(\d_grid_tdev, '/data/netzfrequenz/time_dev', { |msg|
|
||||
~feedSet.(\netz_time_dev, msg[1]);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// RTE eCO2mix -- mix electrique France (MW par filiere)
|
||||
// =====================================================================
|
||||
~_feedDef.(\d_rte_mix, '/data/rte_eco2mix/mix', { |msg|
|
||||
var keys = [\nuclear, \gas, \coal, \oil, \hydro, \wind, \solar, \bio];
|
||||
var total = 0.0;
|
||||
keys.do { |k, i| var v = msg[i+1] ? 0.0; ~feeds[(\rte_ ++ k).asSymbol] = v; total = total + v };
|
||||
~feedSet.(\rte_total, total);
|
||||
// parts renouvelables et carbone tres simple
|
||||
~feedSet.(\rte_renew_pct,
|
||||
if(total > 0) {
|
||||
(~feeds[\rte_hydro] + ~feeds[\rte_wind] + ~feeds[\rte_solar] + ~feeds[\rte_bio]) / total
|
||||
} { 0.0 }
|
||||
);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// BLITZORTUNG -- impacts de foudre
|
||||
// =====================================================================
|
||||
~_feedDef.(\d_lightning_strike, '/data/blitzortung/strike', { |msg|
|
||||
var lat = msg[1], lon = msg[2], age = msg[3], mult = msg[4];
|
||||
~feedSet.(\lightning_last, [lat, lon, age, mult]);
|
||||
});
|
||||
~_feedDef.(\d_lightning_rate, '/data/blitzortung/rate', { |msg|
|
||||
~feedSet.(\lightning_rate_min, msg[1]);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// OPENSKY -- ADS-B
|
||||
// =====================================================================
|
||||
~_feedDef.(\d_aviation_count, '/data/opensky/count', { |msg|
|
||||
~feedSet.(\aviation_count, msg[1]);
|
||||
});
|
||||
~_feedDef.(\d_aviation_plane, '/data/opensky/plane', { |msg|
|
||||
var icao = msg[1], lon = msg[2], lat = msg[3], alt = msg[4], vel = msg[5], head = msg[6];
|
||||
~feedSet.(\aviation_last, [icao, lon, lat, alt, vel, head]);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// BLUESKY JETSTREAM -- pouls social
|
||||
// =====================================================================
|
||||
~_feedDef.(\d_social_post, '/data/bluesky/post', { |msg|
|
||||
~feedSet.(\social_last_len, msg[1]);
|
||||
~feedSet.(\social_last_lang, msg[2]);
|
||||
});
|
||||
~_feedDef.(\d_social_rate, '/data/bluesky/rate', { |msg|
|
||||
~feedSet.(\social_rate_s, msg[1]);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// MEMPOOL -- Bitcoin
|
||||
// =====================================================================
|
||||
~_feedDef.(\d_btc_tx, '/data/mempool/tx', { |msg|
|
||||
~feedSet.(\btc_last_val, msg[1]);
|
||||
~feedSet.(\btc_last_fee, msg[2]);
|
||||
});
|
||||
~_feedDef.(\d_btc_block, '/data/mempool/block', { |msg|
|
||||
~feedSet.(\btc_height, msg[1]);
|
||||
~feedSet.(\btc_block_tx, msg[2]);
|
||||
~feedSet.(\btc_block_reward, msg[3]);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// GITHUB
|
||||
// =====================================================================
|
||||
~_feedDef.(\d_gh_event, '/data/github/event', { |msg|
|
||||
~feedSet.(\gh_last_type, msg[1]);
|
||||
~feedSet.(\gh_last_repo, msg[2]);
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// GCN -- alertes astrophysiques (rares)
|
||||
// =====================================================================
|
||||
~_feedDef.(\d_gcn_alert, '/data/gcn/alert', { |msg|
|
||||
~feedSet.(\gcn_last,
|
||||
[msg[1], msg[2], msg[3], msg[4]] // [mission_hash, ra, dec, err_arcmin]
|
||||
);
|
||||
"*** GCN alert ***".postln; msg.postln;
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Heartbeat -- detection de pont down
|
||||
// =====================================================================
|
||||
~_feedDef.(\d_heartbeat, '/data/heartbeat', { |msg|
|
||||
~feeds[\__last_heartbeat] = SystemClock.seconds;
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Helpers de lecture
|
||||
// =====================================================================
|
||||
~feedGet = { |key, fallback = 0.0|
|
||||
var v = ~feeds[key];
|
||||
if(v.isNil) { fallback } { v }
|
||||
};
|
||||
|
||||
~feedAlive = {
|
||||
var hb = ~feeds[\__last_heartbeat];
|
||||
hb.notNil and: { (SystemClock.seconds - hb) < 15 }
|
||||
};
|
||||
|
||||
~feedDump = {
|
||||
~feeds.keysValuesDo { |k, v|
|
||||
if(k.asString.beginsWith("__").not) {
|
||||
(" " ++ k.asString.padRight(22) ++ " = " ++ v).postln
|
||||
}
|
||||
};
|
||||
("[feeds] heartbeat: " ++ if(~feedAlive.value) { "ALIVE" } { "DOWN" }).postln;
|
||||
};
|
||||
|
||||
"[data_feeds] OSCdef installes (USGS, SWPC, NETZ, RTE, BLITZ, OPENSKY, BSKY, MEMPOOL, GCN).".postln;
|
||||
"[data_feeds] usage : ~feeds[\\swpc_kp], ~feedGet.(\\netz_dev, 0), ~feedDump.()".postln;
|
||||
)
|
||||
@@ -0,0 +1,218 @@
|
||||
// =====================================================================
|
||||
// 16_data_feeds.scd -- Pilotage musical par flux temps reel.
|
||||
//
|
||||
// Prerequis :
|
||||
// [0] Bloc d'init de 01_live.scd (engine + setupAll + wrappers)
|
||||
// [1] Pont Python lance :
|
||||
// cd data_feeds && uv run python bridge.py
|
||||
// [2] OSCdef installes :
|
||||
// ("control/data_feeds.scd").loadRelative
|
||||
//
|
||||
// Trois presets ci-dessous, autonomes, a executer un par un dans l'IDE.
|
||||
// =====================================================================
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [0] Sanity-check : afficher l'etat des flux
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
"sound_algo/control/data_feeds.scd".loadRelative;
|
||||
fork {
|
||||
"[16] verification du pont...".postln;
|
||||
5.do { ~feedDump.value; 2.wait };
|
||||
};
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [1] PRESET A -- "Cavity"
|
||||
// Schumann (drone harmonique) x Netzfrequenz (modulation FM subtile)
|
||||
// x Blitzortung (percussions spatialisees).
|
||||
// Trois echelles temporelles, trois ordres de grandeur en frequence.
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
SynthDef(\cavity_drone, { |out=0, freq=7.83, amp=0.25, fm=0, pan=0|
|
||||
var sig, harm = freq * [1, 2, 3, 4, 5];
|
||||
var amps = [1, 0.5, 0.33, 0.25, 0.2];
|
||||
sig = SinOsc.ar(harm + fm.lag(0.05), 0, amps).sum;
|
||||
sig = sig + (LFTri.ar(freq * 0.5) * 0.1);
|
||||
sig = LeakDC.ar(sig);
|
||||
sig = sig.tanh * amp;
|
||||
Out.ar(out, Pan2.ar(sig, pan));
|
||||
}).add;
|
||||
|
||||
SynthDef(\strike_grain, { |out=0, freq=400, amp=0.5, pan=0, dur=0.4|
|
||||
var env = EnvGen.kr(Env.perc(0.001, dur), doneAction: 2);
|
||||
var sig = WhiteNoise.ar * env;
|
||||
sig = BPF.ar(sig, freq, 0.05) * 6;
|
||||
sig = sig + (SinOsc.ar(freq, 0, env * 0.5));
|
||||
Out.ar(out, Pan2.ar(sig.tanh * amp, pan));
|
||||
}).add;
|
||||
|
||||
// laisse le serveur compiler
|
||||
s.sync;
|
||||
|
||||
~cavity = Synth(\cavity_drone, [\freq, 7.83, \amp, 0.22]);
|
||||
|
||||
// Mod FM par derive de la frequence reseau (dev ~ +-0.05 Hz typique)
|
||||
~cavityFm = Routine({
|
||||
inf.do {
|
||||
var dev = ~feedGet.(\netz_dev, 0);
|
||||
~cavity.set(\fm, dev * 80); // amplification non-lineaire
|
||||
0.1.wait;
|
||||
};
|
||||
}).play(AppClock);
|
||||
|
||||
// Foudre = grain percussif spatialise
|
||||
~strikeSub = ~feedSub.(\lightning_last, { |val|
|
||||
var lat = val[0], lon = val[1], age = val[2], mult = val[3];
|
||||
if(age < 30) {
|
||||
var pan = lon.linlin(-180, 180, -1, 1);
|
||||
var freq = lat.linlin(-90, 90, 200, 3000);
|
||||
Synth(\strike_grain, [
|
||||
\freq, freq, \pan, pan,
|
||||
\amp, mult.linlin(1, 10, 0.4, 0.9),
|
||||
\dur, mult.linlin(1, 10, 0.2, 0.8),
|
||||
]);
|
||||
};
|
||||
});
|
||||
|
||||
"[cavity] up. arret : ~cavityStop.value".postln;
|
||||
~cavityStop = {
|
||||
~cavity.release(2);
|
||||
~cavityFm.stop;
|
||||
~feedUnsub.(\lightning_last, ~strikeSub);
|
||||
"[cavity] off".postln;
|
||||
};
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [2] PRESET B -- "Mix"
|
||||
// eCO2mix (RTE) regle la couleur harmonique : plus de renouvelable
|
||||
// eclaire le filtre, plus de fossile l'assombrit.
|
||||
// OpenSky donne des impulsions melodiques (avions au-dessus de Lyon).
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
SynthDef(\mix_pad, { |out=0, freq=110, amp=0.3, cutoff=800, q=0.3, pan=0|
|
||||
var sig = Mix(Saw.ar(freq * [1, 1.005, 0.503, 2.01]));
|
||||
sig = RLPF.ar(sig, cutoff.lag(0.5).clip(80, 8000), q);
|
||||
sig = sig * amp;
|
||||
Out.ar(out, Pan2.ar(sig, pan));
|
||||
}).add;
|
||||
|
||||
SynthDef(\plane_blip, { |out=0, freq=600, amp=0.3, pan=0|
|
||||
var env = EnvGen.kr(Env.perc(0.01, 0.6), doneAction: 2);
|
||||
var sig = SinOsc.ar(freq * [1, 2.01], 0, [0.7, 0.3]).sum;
|
||||
sig = sig + (Pulse.ar(freq * 0.501) * env * 0.2);
|
||||
sig = (sig * env).tanh * amp;
|
||||
Out.ar(out, Pan2.ar(sig, pan));
|
||||
}).add;
|
||||
|
||||
s.sync;
|
||||
|
||||
~mixPad = Synth(\mix_pad, [\freq, 55, \amp, 0.18]);
|
||||
|
||||
~mixCutoffR = Routine({
|
||||
inf.do {
|
||||
var pct = ~feedGet.(\rte_renew_pct, 0.25);
|
||||
~mixPad.set(\cutoff, pct.linexp(0.0, 0.5, 200, 5000));
|
||||
~mixPad.set(\q, pct.linlin(0.0, 0.5, 0.5, 0.15));
|
||||
2.0.wait;
|
||||
};
|
||||
}).play(AppClock);
|
||||
|
||||
// chaque update OpenSky -> on tire 1 a 3 blips selon la densite
|
||||
~planeSub = ~feedSub.(\aviation_last, { |val|
|
||||
var icao = val[0], lon = val[1], lat = val[2], alt = val[3], vel = val[4];
|
||||
var pan = lon.linlin(4.6, 5.2, -1, 1);
|
||||
// altitude -> note ; vitesse -> brillance
|
||||
var midinote = alt.linlin(0, 12000, 36, 84).clip(24, 96);
|
||||
Synth(\plane_blip, [
|
||||
\freq, midinote.midicps,
|
||||
\pan, pan,
|
||||
\amp, vel.linlin(50, 300, 0.1, 0.35),
|
||||
]);
|
||||
});
|
||||
|
||||
"[mix] up. arret : ~mixStop.value".postln;
|
||||
~mixStop = {
|
||||
~mixPad.release(3);
|
||||
~mixCutoffR.stop;
|
||||
~feedUnsub.(\aviation_last, ~planeSub);
|
||||
"[mix] off".postln;
|
||||
};
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [3] PRESET C -- "Geomagnetic"
|
||||
// Kp (geomagnetisme) ouvre/ferme un filtre passe-bande.
|
||||
// X-ray flares declenchent un drop dramatique.
|
||||
// USGS earthquakes -> sub-bass ponctuel proportionnel a la magnitude.
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
SynthDef(\geo_bus, { |out=0, freq=200, amp=0.4, bw=0.4|
|
||||
var sig = Mix(Saw.ar([55, 110, 165]));
|
||||
sig = BPF.ar(sig, freq.lag(0.3).clip(80, 6000), bw.lag(0.3).clip(0.05, 1));
|
||||
sig = sig * 1.5;
|
||||
Out.ar(out, Splay.ar([sig, DelayN.ar(sig, 0.05, 0.03)]) * amp);
|
||||
}).add;
|
||||
|
||||
SynthDef(\quake_sub, { |out=0, freq=40, amp=0.7, dur=2|
|
||||
var env = EnvGen.kr(Env.perc(0.05, dur, 1, -2), doneAction: 2);
|
||||
var sig = SinOsc.ar(freq) * env;
|
||||
sig = sig + (LFTri.ar(freq * 0.5) * env * 0.3);
|
||||
sig = sig.tanh * amp;
|
||||
Out.ar(out, Pan2.ar(sig, 0));
|
||||
}).add;
|
||||
|
||||
s.sync;
|
||||
|
||||
~geo = Synth(\geo_bus, [\amp, 0.25]);
|
||||
|
||||
~geoKpR = Routine({
|
||||
inf.do {
|
||||
var kp = ~feedGet.(\swpc_kp, 2);
|
||||
// Kp 0..9 -> ouvre 200..3000 Hz
|
||||
~geo.set(\freq, kp.linexp(0, 9, 200, 3000));
|
||||
~geo.set(\bw, kp.linlin(0, 9, 0.5, 0.05));
|
||||
5.0.wait;
|
||||
};
|
||||
}).play(AppClock);
|
||||
|
||||
~flareSub = ~feedSub.(\swpc_flare_norm, { |val|
|
||||
if(val > 0.5) {
|
||||
// X-class flare : choc
|
||||
~geo.set(\amp, 0.6);
|
||||
AppClock.sched(3.0, { ~geo.set(\amp, 0.25); nil });
|
||||
"*** flare ***".postln;
|
||||
};
|
||||
});
|
||||
|
||||
~quakeSub = ~feedSub.(\usgs_last_mag, { |val|
|
||||
Synth(\quake_sub, [
|
||||
\freq, val.linexp(2, 7, 30, 120),
|
||||
\dur, val.linlin(2, 7, 1, 5),
|
||||
\amp, val.linlin(2, 7, 0.4, 0.9),
|
||||
]);
|
||||
});
|
||||
|
||||
"[geo] up. arret : ~geoStop.value".postln;
|
||||
~geoStop = {
|
||||
~geo.release(2);
|
||||
~geoKpR.stop;
|
||||
~feedUnsub.(\swpc_flare_norm, ~flareSub);
|
||||
~feedUnsub.(\usgs_last_mag, ~quakeSub);
|
||||
"[geo] off".postln;
|
||||
};
|
||||
)
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// [9] ARRET TOTAL
|
||||
// ---------------------------------------------------------------------
|
||||
(
|
||||
~cavityStop !? { ~cavityStop.value };
|
||||
~mixStop !? { ~mixStop.value };
|
||||
~geoStop !? { ~geoStop.value };
|
||||
)
|
||||
Reference in New Issue
Block a user