For a long time, Node-RED was the glue holding a few of my Home Assistant automations together. It’s a capable tool, and for visual flow-based logic it makes a lot of sense on paper. But if I’m honest, I never fully got comfortable with it. Flows would occasionally stop firing for reasons I couldn’t pin down, debugging meant clicking through a maze of nodes instead of reading a clear error message, and every time I came back to a flow after a few months I had to relearn how I’d wired it together. It wasn’t Node-RED’s fault so much as a mismatch between how I think about automation logic and how flow-based programming works. I know Python. I don’t “know” Node-RED, not really — and that gap in understanding kept causing small, hard-to-diagnose instability.
One of the flows still running in Node-RED was translating Dutch weather text into English — specifically the KNMI weather warning text, plus the Buienradar forecast summaries I use elsewhere in my dashboards. Small task, but exactly the kind of thing I wanted to bring back into Home Assistant natively, using pyscript, so I could actually read and reason about the code doing the work.
Building a shared DeepL translation service
Rather than writing a separate translation routine per sensor, I built one small pyscript service that any automation can call:
import requests
@service(supports_response="only")
def deepl_translate(
text=None,
source_lang="NL",
target_lang="EN",
return_response=True
):
if not text:
return {
"success": False,
"translation": "",
"error": "No text supplied"
}
api_key = pyscript.config.get("deepl_api_key")
if not api_key:
return {
"success": False,
"translation": "",
"error": "DeepL API key not configured"
}
url = "https://api-free.deepl.com/v2/translate"
headers = {
"Authorization": f"DeepL-Auth-Key {api_key}"
}
data = {
"text": text,
"source_lang": source_lang,
"target_lang": target_lang
}
try:
response = task.executor(
requests.post,
url,
headers=headers,
data=data,
timeout=30
)
response.raise_for_status()
result = response.json()
translation = result["translations"][0]["text"]
log.info(
f"DeepL translation completed: "
f"{len(text)} -> {len(translation)} characters"
)
return {
"success": True,
"translation": translation
}
except Exception as err:
log.error(f"DeepL translation failed: {err}")
return {
"success": False,
"translation": "",
"error": str(err)
}
It takes a piece of Dutch text, sends it to the DeepL API, and returns a response object with the translated text — or a clear success/error flag if something goes wrong. The API key lives in secrets.yaml, referenced through pyscript’s config rather than hardcoded anywhere.
This turned out to be the right level of abstraction. It doesn’t care which sensor the text came from, so I can reuse it for Buienradar today and anything else — a translated notification, a translated calendar event — later, without touching the translation logic again.
Getting the trigger logic actually working
This part took longer than I expected, and it’s worth being honest about it: my first instinct was to wire the translation directly into pyscript using state-trigger decorators watching each sensor’s relevant attribute. On paper it should have worked. In practice, the app-scoped configuration pyscript expects, where a script picks up its API key and settings from an apps block in configuration.yaml, only resolves correctly for a file that is genuinely the app’s main file — and getting that wiring exactly right, across folder structure and file naming, turned into a longer troubleshooting session than the actual translation logic deserved.
The fix that stuck was to separate concerns: keep the DeepL call as a simple, stateless pyscript service, and let a regular Home Assistant automation own the triggering. That split turned out to be more transparent anyway — I can see the automation trace for every translation, exactly which sensor attribute changed, what got sent to DeepL, and what came back, all in the built-in automation UI. No separate log-reading required.
The automation
One automation now watches all four sources and calls the shared translation service:
- A state trigger per sensor/attribute
- A template condition that skips the call if the source text is empty, saving API quota
- A call to pyscript.deepl_translate with response_variable capturing the result
- A condition confirming the translation actually succeeded before writing anywhere
- The result written to a matching input_text helper, truncated to 250 characters with a trailing ‘…’ so it never exceeds the helper’s storage limit
alias: 'Buienradar: Translate forecasts to English'
description: Translate all current Buienradar/KNMI values
triggers:
- trigger: state
entity_id: sensor.buienradar_weatherreport
attribute: summary
id: weatherreport
- trigger: state
entity_id: sensor.buienradar_shortterm
attribute: forecast
id: shortterm
- trigger: state
entity_id: sensor.buienradar_longterm
attribute: forecast
id: longterm
- trigger: state
entity_id: sensor.home_weather_code
id: code
- trigger: state
entity_id: sensor._weather_forecast
id: warning
conditions: []
actions:
- variables:
weatherreport: |
{{ state_attr('sensor.buienradar_weatherreport', 'summary')
| default('', true) }}
shortterm: |
{{ state_attr('sensor.buienradar_shortterm', 'forecast')
| default('', true) }}
longterm: |
{{ state_attr('sensor.buienradar_longterm', 'forecast')
| default('', true) }}
knmi_code: |
{{ states('sensor.home_weather_code') }}
knmi_warning: |
{{ states('sensor.local_weather_forecast') }}
- action: pyscript.deepl_translate
data:
text: '{{ weatherreport }}'
source_lang: NL
target_lang: EN
response_variable: deepl_response
- action: input_text.set_value
target:
entity_id: input_text.buienradar_weatherreport_en
data:
value: >
{% set text = deepl_response.translation %} {{ text[:250] ~ '...' if
text | length > 250 else text }}
- action: pyscript.deepl_translate
data:
text: '{{ shortterm }}'
source_lang: NL
target_lang: EN
response_variable: deepl_response
- action: input_text.set_value
target:
entity_id: input_text.buienradar_shortterm_en
data:
value: >
{% set text = deepl_response.translation %} {{ text[:250] ~ '...' if
text | length > 250 else text }}
- action: pyscript.deepl_translate
data:
text: '{{ longterm }}'
source_lang: NL
target_lang: EN
response_variable: deepl_response
- action: input_text.set_value
target:
entity_id: input_text.buienradar_longterm_en
data:
value: >
{% set text = deepl_response.translation %} {{ text[:250] ~ '...' if
text | length > 250 else text }}
- action: pyscript.deepl_translate
data:
text: '{{ knmi_code }}'
source_lang: NL
target_lang: EN
response_variable: deepl_response
- action: input_text.set_value
target:
entity_id: input_text.buienradar_knmi_code_en
data:
value: >
{% set text = deepl_response.translation %} {{ text[:250] ~ '...' if
text | length > 250 else text }}
- action: pyscript.deepl_translate
data:
text: '{{ knmi_warning }}'
source_lang: NL
target_lang: EN
response_variable: deepl_response
- action: input_text.set_value
target:
entity_id: input_text.buienradar_knmi_warning_en
data:
value: >
{% set text = deepl_response.translation %} {{ text[:250] ~ '...' if
text | length > 250 else text }}
mode: single
Configuration.yaml and secrets.yaml
Both the automation and the pyscript service rely on this base configuration to make the DeepL API key available:
# configuration.yaml
python_script:
pyscript:
allow_all_imports: true
hass_is_global: true
deepl_api_key: !secret deepl_api_key
apps:
deepl:
api_key: !secret deepl_api_key
# secrets.yaml
deepl_api_key: "your-deepl-api-key-here"
Replace the placeholder in secrets.yaml with your own DeepL API key.
Translation table
| Original sensor | Relevant field | English output helper |
|---|---|---|
| sensor.buienradar_weatherreport | attribute summary | input_text.buienradar_weatherreport_en |
| sensor.buienradar_shortterm | attribute forecast | input_text.buienradar_shortterm_en |
| sensor.buienradar_longterm | attribute forecast | input_text.buienradar_longterm_en |
| sensor.home_weather_code | full state | input_text.buienradar_knmi_code_en |
| sensor.<knmi-city>_weather_forecast | full state | input_text.buienradar_knmi_warning_en |
All four flow through the same pyscript.deepl_translate service, called from a single queued automation (mode: queued, max: 10) so overlapping sensor updates don’t step on each other.
Where this leaves Node-RED
With this piece moved over, the weather translation flow in Node-RED is now fully redundant — everything it used to do runs natively in Home Assistant, in code I can actually read, debug, and extend. It’s one more flow off the list, and honestly one of the more satisfying ones to retire, since it was also one of the flakier ones. There’s still a bit left in Node-RED, but each piece that moves over follows roughly the same shape: a small, focused pyscript service doing the actual work, and a plain HA automation deciding when to call it. That combination is proving a lot easier for me to reason about — and trust — than a flow diagram ever was.
Related Posts
May 30, 2026
Monitor QNAP Updates in Home Assistant
May 28, 2026


