diff --git a/apps/api_server/src/twfarmbot_api_server/handlers/pin.py b/apps/api_server/src/twfarmbot_api_server/handlers/pin.py index c0c3679..b633c87 100644 --- a/apps/api_server/src/twfarmbot_api_server/handlers/pin.py +++ b/apps/api_server/src/twfarmbot_api_server/handlers/pin.py @@ -2,14 +2,29 @@ from __future__ import annotations +from twfarmbot_core.config import load_yaml_config from twfarmbot_core.domain import Action from watering_service.backends import farmbot +def _pin_mode(pin: int, requested: str | None) -> str: + """Return the requested pin mode, falling back to the configured mode.""" + if requested: + return str(requested) + try: + pins = load_yaml_config().get("pins", []) or [] + for p in pins: + if int(p.get("pin", -1)) == pin: + return str(p.get("mode", "digital")) + except Exception: # noqa: BLE001 + pass + return "digital" + + def handle_read_pin(action: Action) -> Action: pin = int(action.params["pin"]) - mode = str(action.params.get("mode", "digital")) + mode = _pin_mode(pin, action.params.get("mode")) value = farmbot.backend.read_pin(pin, mode) return Action(kind=action.kind, params={ "pin": pin, "mode": mode, "value": value @@ -19,7 +34,7 @@ def handle_read_pin(action: Action) -> Action: def handle_write_pin(action: Action) -> Action: pin = int(action.params["pin"]) value = int(action.params["value"]) - mode = str(action.params.get("mode", "digital")) + mode = _pin_mode(pin, action.params.get("mode")) seconds = action.params.get("seconds") duration = None if value == 1 and seconds is not None: diff --git a/apps/ui/src/twfarmbot_ui/app.py b/apps/ui/src/twfarmbot_ui/app.py index 7ac51b7..1f38306 100644 --- a/apps/ui/src/twfarmbot_ui/app.py +++ b/apps/ui/src/twfarmbot_ui/app.py @@ -1121,7 +1121,7 @@ def _render_io() -> None: cols = st.columns(min(3, len(sensors))) for i, s in enumerate(sensors): with cols[i]: - with st.container(border=True, height=170): + with st.container(border=True): mode = s.get("mode", "analog") st.markdown( f"**{s['label']}** {mode}", @@ -1151,7 +1151,7 @@ def _render_io() -> None: st.markdown("### ⚡ Actuators") a, b = st.columns(2) with a: - with st.container(border=True, height=320): + with st.container(border=True): st.markdown("**💧 Irrigation**") secs = st.number_input("Seconds", 0.1, 300.0, 2.0, 0.5, key="water_secs") if st.button("Water", use_container_width=True, type="primary"): @@ -1167,7 +1167,7 @@ def _render_io() -> None: st.caption("Runs the pump for the selected duration.") with b: - with st.container(border=True, height=320): + with st.container(border=True): st.markdown("**🔌 Peripheral control**") outputs = [p for p in named if p.get("kind") != "sensor"] if not outputs: @@ -1187,6 +1187,18 @@ def _render_io() -> None: ) if mode == "analog": + presets = sel.get("presets") or {} + if presets: + st.caption("Presets") + preset_cols = st.columns(len(presets)) + for idx, (pval, plabel) in enumerate(sorted(presets.items(), key=lambda x: int(x[0]))): + if preset_cols[idx].button( + f"{plabel} ({pval})", + use_container_width=True, + key=f"preset_{sel['pin']}_{pval}", + ): + _do_pin_write(client, sel["pin"], int(pval), mode) + val_col, btn_col = st.columns([4, 1]) with val_col: analog_value = st.slider( diff --git a/configs/dev.yaml b/configs/dev.yaml index df11856..7320b0b 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -7,7 +7,7 @@ log_level: INFO # the environment, or reference a different env var via api_key_env. planning: base_url: https://openrouter.ai/api/v1 # OpenAI-compatible endpoint - model: openai/gpt-4o-mini # tool-calling model + model: ibm-granite/granite-4.1-8b # tool-calling model api_key_env: PLANNING_LLM_API_KEY # name of the env var that holds the secret timeout_s: 120 # raise this for slower models temperature: 0.0 @@ -38,15 +38,16 @@ safety: watering: pump_pin: 7 -# Named pins for the UI: label → {pin, mode, kind, group}. +# Named pins for the UI: label → {pin, mode, kind, group, presets}. # kind: "valve" | "servo" | "sensor" | "io" # group: shown as a column header in the pin grid +# presets (optional): value → label map for analog pins, e.g. {0: "Off", 120: "Cut"} # Every pin number must be unique; a single GPIO can't drive a valve and # read a sensor at the same time. pins: - {label: "Pump", pin: 7, mode: digital, kind: valve, group: "Water"} - {label: "Water solenoid", pin: 8, mode: digital, kind: valve, group: "Water"} -- {label: "Cutting end effector", pin: 4, mode: analog, kind: io, group: "Tools"} +- {label: "Cutting end effector", pin: 4, mode: analog, kind: io, group: "Tools", presets: {0: "Off", 120: "Cut", 255: "Max"}} - {label: "Tool servo", pin: 10, mode: digital, kind: servo, group: "Tools"} - {label: "Soil sensor", pin: 14, mode: analog, kind: sensor, group: "Sensors"} - {label: "Light sensor", pin: 15, mode: analog, kind: sensor, group: "Sensors"} diff --git a/services/planning_service/planning_service/harness/context_builder.py b/services/planning_service/planning_service/harness/context_builder.py index ab35ff4..4176bb1 100644 --- a/services/planning_service/planning_service/harness/context_builder.py +++ b/services/planning_service/planning_service/harness/context_builder.py @@ -11,6 +11,7 @@ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from spatial_service import format_world_context +from twfarmbot_core.config import load_yaml_config from .tool_policy import ToolCategory from .tool_registry import ToolRegistry @@ -112,6 +113,7 @@ def __init__( def chat_system_prompt(self) -> str: parts = [_CHAT_HEADER] parts.append(self._render_tool_section()) + parts.append(_format_pin_context()) parts.append(_CHAT_FOOTER) if self._propose_only: parts.append(_PROPOSE_ONLY_APPENDIX) @@ -125,6 +127,7 @@ def chat_system_prompt(self) -> str: def planner_system_prompt(self) -> str: parts = [_PLANNER_HEADER] parts.append(self._render_tool_section(for_planner=True)) + parts.append(_format_pin_context()) parts.append(_PLANNER_FOOTER) return "\n".join(parts) @@ -225,3 +228,37 @@ def _render_tool_section(self, for_planner: bool = False) -> str: lines.append(f"- `{d.name}` — {d.policy.description}{approval}") return "\n".join(lines) + + +def _format_pin_context() -> str: + """Load named pins from config and format them for the system prompt.""" + try: + pins = load_yaml_config().get("pins", []) or [] + except Exception: # noqa: BLE001 + pins = [] + if not pins: + return "" + lines = ["\nConfigured GPIO pins (single source of truth):"] + for p in pins: + label = p.get("label", "unknown") + pin = p.get("pin", "?") + mode = p.get("mode", "digital") + kind = p.get("kind", "io") + group = p.get("group", "") + group_text = f" · {group}" if group else "" + presets = p.get("presets") or {} + preset_text = "" + if presets: + preset_items = ", ".join( + f"{v}={lbl}" for v, lbl in sorted(presets.items(), key=lambda x: int(x[0])) + ) + preset_text = f" · presets: {preset_items}" + lines.append( + f"- pin {pin} · {label} · mode={mode} · kind={kind}{group_text}{preset_text}" + ) + lines.append( + "When calling read_pin or write_pin, use the configured mode for the pin " + "unless the user explicitly asks for a different mode. For analog pins, " + "use the named preset values when the user refers to them." + ) + return "\n".join(lines) diff --git a/services/planning_service/planning_service/tools.py b/services/planning_service/planning_service/tools.py index 0c6553e..687fe21 100644 --- a/services/planning_service/planning_service/tools.py +++ b/services/planning_service/planning_service/tools.py @@ -39,13 +39,31 @@ class FindHomeArgs(BaseModel): class ReadPinArgs(BaseModel): pin: int = Field(..., description="GPIO pin number to read.") - mode: str = Field(default="digital", description="'digital' or 'analog'.") + mode: str = Field( + default="digital", + description=( + "'digital' or 'analog'. If omitted, default to the mode configured " + "for this pin in the system config." + ), + ) class WritePinArgs(BaseModel): pin: int = Field(..., description="GPIO pin number to write.") - value: int = Field(..., description="0 or 1.") - mode: str = Field(default="digital", description="'digital' or 'analog'.") + value: int = Field( + ..., + description=( + "Value to write. For digital pins use 0 or 1. For analog pins use " + "0..255 (PWM)." + ), + ) + mode: str = Field( + default="digital", + description=( + "'digital' or 'analog'. If omitted, default to the mode configured " + "for this pin in the system config." + ), + ) class MountToolArgs(BaseModel):