diff --git a/controller/MCP4725.py b/controller/MCP4725.py index a25d0850b..90db8f67c 100644 --- a/controller/MCP4725.py +++ b/controller/MCP4725.py @@ -67,3 +67,13 @@ def get_value() -> float: def set_value(value: float) -> None: assert dac is not None dac.normalized_value = value + + +def get_raw_value() -> int: + assert dac is not None + return int(dac.raw_value) + + +def set_raw_value(value: int) -> None: + assert dac is not None + dac.raw_value = value diff --git a/controller/bubbler/README.md b/controller/bubbler/README.md index cb6199b37..ee0bab831 100644 --- a/controller/bubbler/README.md +++ b/controller/bubbler/README.md @@ -31,9 +31,12 @@ just dev ```json { "action": "on", + "value": 0.5, } ``` +`value` is a float >= `0` <= `1` that will adjust the bubbler intensity. The default is `1`. + ### Stop the bubbler: **topic** `actuator/bubbler` @@ -49,14 +52,15 @@ just dev **topic** `status/bubbler` -**payload when started:** +**payload when on:** ```json { "status": "On", + "value": 0.5, } ``` -**payload when stopped:** +**payload when off:** ```json { "status": "Off", diff --git a/controller/bubbler/main.py b/controller/bubbler/main.py index ddcad6efd..8389f6df6 100644 --- a/controller/bubbler/main.py +++ b/controller/bubbler/main.py @@ -1,9 +1,7 @@ import asyncio import json -import math import signal import sys -import time from pprint import pprint import aiomqtt # type: ignore @@ -13,26 +11,41 @@ client = None loop = asyncio.new_event_loop() bubbler = None +state_value = 0 -# ============== ADJUST THESE VALUES ============== -# La moyenne exacte entre ton Peak (0.275) et ta Valley (0.265) -OSCILLATION_AVERAGE = 0.264 +# Calibrated configuration +DAC_MIN_START = 1114 # Value at 25% +DAC_MAX_POWER = 1433 # Value at 100% +KICKSTART_DURATION = 0.1 # 100ms to overcome inertia -# L'amplitude pour atteindre exactement tes limites -# 0.270 + 0.005 = 0.275 (Peak) -# 0.270 - 0.005 = 0.265 (Valley) -OSCILLATION_AMPLITUDE = 0.005 -# Vitesse de l'oscillation en Hertz (cycles par seconde). -# 5.0 correspond à ton ancien cycle de 0.2 seconde. -OSCILLATION_FREQUENCY = 4 +# Translate a normalized value 0-1 to a DAC value with a special calibration : 0.25 -> 0.272, 1.0 -> 0.350. +def map_value_to_dac(value: float) -> int: + value = max(0.0, min(value, 1.0)) -# Fréquence de mise à jour du DAC (en secondes). -# 0.01 offre une courbe très fluide à 100fps. -UPDATE_INTERVAL = 0.02 -# ================================================= + # For values between 1% and 24% (e.g. via slider) + # we slowly increase between 0 and DAC_MIN_START + if value < 0.25: + dac = (value / 0.25) * DAC_MIN_START + # Strict linear interpolation for range [25% - 100%] + # At 25%, the right hand side is 0, we've got DAC_MIN_START + # At 100%, the fraction equals 1, we've got DAC_MAX_POWER + else: + dac = DAC_MIN_START + ((value - 0.25) / 0.75) * (DAC_MAX_POWER - DAC_MIN_START) -oscillation_task = None + return int(round(max(0, min(dac, DAC_MAX_POWER)))) + + +# Reverse of map_value_to_dac +def map_dac_to_value(dac: int) -> float: + dac = max(0, min(dac, DAC_MAX_POWER)) + + if dac < DAC_MIN_START: + value = (dac / DAC_MIN_START) * 0.25 + else: + value = 0.25 + ((dac - DAC_MIN_START) / (DAC_MAX_POWER - DAC_MIN_START)) * 0.75 + + return max(0.0, min(value, 1.0)) async def start() -> None: @@ -45,6 +58,10 @@ async def start() -> None: import MCP4725 as bubbler bubbler.init(address=0x60) + global state_value + # Restore value from DAC + state_value = map_dac_to_value(bubbler.get_raw_value()) + global client client = aiomqtt.Client(hostname="localhost", port=1883, protocol=aiomqtt.ProtocolVersion.V5) task_group = asyncio.TaskGroup() @@ -81,96 +98,53 @@ async def handle_action(action: str, payload) -> None: assert bubbler is not None if action == "on": - await on() + await on(payload) elif action == "off": await off() - # elif action == "settings": - # await handle_settings(payload) elif action == "save": - if hasattr(bubbler, "save"): - bubbler.save() - + await save() -# async def handle_settings(payload) -> None: -# assert bubbler is not None -# if "current" in payload["settings"]: -# # {"settings":{"current":"20"}} -# current = payload["settings"]["current"] -# if bubbler.is_on(): -# return -# bubbler.set_current(current) - - -async def on() -> None: - global oscillation_task +async def on(payload) -> None: assert bubbler is not None + value = payload.get("value", 1) + value = max(0.0, min(value, 1)) - if oscillation_task: - oscillation_task.cancel() - try: - await oscillation_task - except asyncio.CancelledError: - pass - oscillation_task = None - - # Kick-start: high intensity to prime the pump - bubbler.set_value(0.275) - await asyncio.sleep(2) - - # Start oscillation mode - oscillation_task = asyncio.create_task(run_oscillate()) - await publish_status() - - -async def run_oscillate(): - """Smoothly oscillate current using a sine wave to pulse the bubbler.""" - assert bubbler is not None - start_time = time.time() - try: - while True: - # Calculate elapsed time - t = time.time() - start_time - - # Generate sine wave value between -1 and 1 - sine_wave = math.sin(2 * math.pi * OSCILLATION_FREQUENCY * t) - - # Scale to our desired amplitude and shift to our average - current_val = OSCILLATION_AVERAGE + (OSCILLATION_AMPLITUDE * sine_wave) + if value == 0: + await off() + return - # Safety clamp to ensure we never pass invalid values to the DAC - current_val = max(0.0, min(1.0, current_val)) + global state_value + state_value = value - bubbler.set_value(current_val) + # If pump was off, kickstart + if value >= 0.25 and bubbler.is_off(): + bubbler.set_raw_value(DAC_MAX_POWER) # Kickstart with max power + await asyncio.sleep(KICKSTART_DURATION) - await asyncio.sleep(UPDATE_INTERVAL) - except asyncio.CancelledError: - pass + bubbler.set_raw_value(map_value_to_dac(value)) + await publish_status() async def off() -> None: - global oscillation_task - if oscillation_task: - oscillation_task.cancel() - try: - await oscillation_task - except asyncio.CancelledError: - pass - oscillation_task = None assert bubbler is not None bubbler.off() + global state_value + state_value = 0 await publish_status() +async def save() -> None: + bubbler.save() + + async def publish_status() -> None: assert bubbler is not None assert client is not None - value = bubbler.get_value() - payload = { "status": "Off" if bubbler.is_off() else "On", - "value": value, + "value": state_value, } await client.publish(topic="status/bubbler", payload=json.dumps(payload), retain=True) diff --git a/controller/bubbler/test.js b/controller/bubbler/test.js index d9f2d56d1..f34392d17 100644 --- a/controller/bubbler/test.js +++ b/controller/bubbler/test.js @@ -7,8 +7,11 @@ watch("status/bubbler").then(async (messages) => { } }) -await startBubbler() +await stopBubbler() -await setTimeout(4000) +for (const value of [0.25, 0.5, 0.75, 100]) { + await startBubbler({ value }) + await setTimeout(2000) +} await stopBubbler() diff --git a/controller/light/main.py b/controller/light/main.py index 8efc893d2..c7e8c3b7b 100644 --- a/controller/light/main.py +++ b/controller/light/main.py @@ -71,28 +71,14 @@ async def handle_action(action: str, payload) -> None: await on(payload) elif action == "off": await off() - # elif action == "settings": - # await handle_settings(payload) elif action == "save": - if hasattr(led, "save"): - led.save() - - -# async def handle_settings(payload) -> None: -# assert led is not None - -# if "current" in payload["settings"]: -# # {"settings":{"current":"20"}} -# current = payload["settings"]["current"] -# if led.is_on(): -# return -# led.set_current(current) + await save() async def on(payload) -> None: assert led is not None value = payload.get("value", 1) - assert 0.0 <= value <= 1.0 + value = max(0.0, min(value, 1)) if value == 0: await off() @@ -120,6 +106,13 @@ async def off() -> None: print(e) +async def save() -> None: + assert led is not None + + if hasattr(led, "save"): + led.save() + + async def publish_status() -> None: assert client is not None assert led is not None diff --git a/frontend/src/pages/preview/NumberInput.jsx b/frontend/src/pages/preview/NumberInput.jsx index 34ca83c37..1195f82cb 100644 --- a/frontend/src/pages/preview/NumberInput.jsx +++ b/frontend/src/pages/preview/NumberInput.jsx @@ -11,6 +11,10 @@ export default function NumberInput(props) { props.onChange?.(evt.target.valueAsNumber) } + const min = props.min || "0" + const max = props.max || "1" + const step = props.step || "0.1" + return (
) diff --git a/frontend/src/pages/preview/index.jsx b/frontend/src/pages/preview/index.jsx index f663b00b3..b4fef8540 100644 --- a/frontend/src/pages/preview/index.jsx +++ b/frontend/src/pages/preview/index.jsx @@ -2,31 +2,28 @@ import Stream from "./Stream.jsx" import styles from "./styles.module.css" import "./reader.js" -import { - startLight, - startBubbler, - watch, - stopBubbler, -} from "../../../../lib/scope.js" +import { startLight, startBubbler, watch } from "../../../../lib/scope.js" import { triggerDownload, makeUrl } from "../../helpers.js" import NumberInput from "./NumberInput.jsx" import { createSignal } from "solid-js" export default function Preview() { - const [bubbler, setBubbler] = createSignal(false) - const [light_dac, setLightDac] = createSignal(0) + const [bubbler_value, setBubblerValue] = createSignal(0) + const [light_value, setLightValue] = createSignal(0) watch("status/bubbler").then(async (messages) => { for await (const message of messages) { - setBubbler(message.status === "On") + if (message.value) { + setBubblerValue(message.value) + } } }) watch("status/light").then(async (messages) => { for await (const message of messages) { - if (message.dac) { - setLightDac(message.dac) + if (message.value) { + setLightValue(message.value) } } }) @@ -38,18 +35,18 @@ export default function Preview() {

Light

Bubbler

- -
@@ -66,10 +63,8 @@ function onLightChange(value) { }) } -function onBubblerChange(event) { - if (event.target.checked === true) { - startBubbler() - } else { - stopBubbler() - } +function onBubblerChange(value) { + startBubbler({ + value, + }) }