Eines der Widgets, welches ich in meinem Pokémon-Floorplan verwende und extra dafür hergestellt habe. Folgende Funktionen sind damit beabsichtigt:
- Animation des Pokemons
- Auswahl des Bildes anhand der Nummer
- Anzeige von aktuellen Mess- oder Prognosewerten als Lebensleiste
- Definition eines totalen Maximums oder aktuellen Maximums anhand von absoluten Werten oder von Items (z.B. max. UV-Wert von 10, Tagesmaximum von 6 und dabei aktueller Wert von 3.5)
- Verarbeitung von Einheiten
- Anzeige auch von bis zu zwei künftigen Werten (z.B. Prognose von morgen und übermorgen)
- Evolution der Pokemon anhand von Schwellwerten (z.B. Bisasam bei Pollenwert <= 1, Bisaknosp bis <=2 und darüber Bisafloor)
- Öffnen von Details (Gruppenseite) bein Anklicken
- Speziell: Messrichtungen mit individuellem Symbol (z.B. Windrichtung)


Vorbereitungen
Im Vorfeld musst du für dein OpenHAB die Pokemon als .png-Files bereitstellen. Dabei muss das File jeweils beide Bewegungsvariaten beinhalten, wie es im Archiv Pokémon Essentials V 17.2 2017 10 15 der Fall ist. Am einfachsten kannst du die Pokemon aus diesem Set einfach in deinem OpenHAB-config-Ordner im Unterordner html (ggf. /etc/openhab/html) hochladen, welcher dann unter http://[dein-openhab]/static verfügbar ist
Widget
Den Code fürs Widget kannst du in OpenHAB direkt in den Entwicklereinstellungen als neues Widget anlegen und konfigurieren.
1. Kopfbereich (uid
, props
, parameterGroups
)
uid: ha_pokemon
→ das ist einfach die eindeutige Kennung des Widgets.props
→ hier werden Einstellungen definiert, die man später im OpenHAB UI setzen kann (z. B. welches Pokémon angezeigt wird, welche Items für „Health“ genutzt werden usw.).- Beispiel:
pokemon: 001
bedeutet, dass standardmäßig Pokémon Nr. 001 (Bisasam) angezeigt wird. threshold2
,threshold3
sind Grenzwerte: wenn die Gesundheit hoch genug ist, entwickelt sich das Pokémon in Stufe 2 oder 3.health1
,health2
,health3
sind die Items aus OpenHAB, die den Gesundheitswert für heute, morgen oder den 3. Tag liefern.max
bzw.max_item
geben an, wie viele maximale HP es insgesamt gibt.- Unter
actions
kann man einstellen, ob das Pokémon beim Anklicken etwas auslöst (z. B. Schalter umlegen). windcircle
ist eine Zusatzfunktion: hier wird ein Kreis mit Pfeil angezeigt, der z. B. eine Windrichtung darstellt.
- Beispiel:
parameterGroups
→ ordnet diese Einstellungen in Gruppen (Werte, Evolutionsstufen, Aktionen, Windkreis), damit es im Editor übersichtlicher bleibt.
2. config: stylesheet
(Design und Animation)
Hier steckt das Aussehen drin, mit CSS definiert:
- Farben & Schatten:
--dark-color: #444
ist ein dunkler Grundton. - Animationen (
@keyframes
):showDay10
undshowDay5
steuern, wie Werte für Tag 1, 2, 3 nacheinander eingeblendet werden.
- Health-Bar:
health-bar-bg
→ der Hintergrundbalken.health-bar-fill
→ der grüne Füllbalken, der je nach Wert kürzer oder länger ist.
- Text:
.value
zeigt den Zahlenwert (z. B. 80/100)..name
zeigt den Pokémon-Namen.
- Sprite-Anzeige:
#sprite-viewport
ist der Rahmen, in dem das Pokémon angezeigt wird..sprite
ist das eigentliche Pokémon-Bild, das zwischen zwei Frames „wackelt“, sodass es wie animiert wirkt.
3. Inhalt (slots: default
)
Das ist die eigentliche Struktur des Widgets:
- Wind-Kreis: ein runder Rahmen mit Pfeil (
oh-icon
), der sich nach demdirection
-Wert dreht. Damit kann man z. B. die Windrichtung grafisch darstellen. - Pokémon-Container (
.container
):- Enthält die Health-Bar mit dynamischen Balken.
oh-repeater
→ sorgt dafür, dass mehrere Tage (1–3) durchlaufen und angezeigt werden können. Jeder Tag hat:- einen Balken (Füllung, passend zum Wert),
- einen Wert (z. B. „85/100 kWh“),
- und den Namen (z. B. „Pikachu +1d“ für morgen).
- Die Animation (
showDay10
,showDay5
) blendet die Tage automatisch abwechselnd ein, wenn mehrere Forecast-Tage aktiviert sind.
- Pokémon-Bild (
oh-button
mitsprite
):- Hier wird entschieden, welches Pokémon angezeigt wird:
- Wenn der Wert über
threshold3
liegt → Pokémon 3 (z. B. Glurak). - Sonst, wenn über
threshold2
→ Pokémon 2 (z. B. Glutexo). - Sonst bleibt es bei Pokémon 1 (z. B. Glumanda).
- Wenn der Wert über
- Das Ganze steckt in einem Button → dadurch kann man beim Antippen Aktionen auslösen (Schalter toggeln oder Gruppen-Popup öffnen).
- Hier wird entschieden, welches Pokémon angezeigt wird:
Widget-Code
uid: ha_pokemon
tags: []
props:
parameters:
- default: "/static/floorplan/icons/Pokemon/Icons/icon"
label: PNG base path
name: base_path
required: true
type: TEXT
- default: "001"
label: Pokémon No.
name: pokemon
required: true
type: TEXT
groupName: evolutions
- label: Threshold Evolution 2
name: threshold2
required: false
type: DECIMAL
groupName: evolutions
advanced: true
- default: "002"
label: Evolution 2 No.
name: pokemon2
required: false
type: TEXT
groupName: evolutions
advanced: true
- label: Threshold Evolution 3
name: threshold3
required: false
type: DECIMAL
groupName: evolutions
advanced: true
- default: "003"
label: Evolution 3 No.
name: pokemon3
required: false
type: TEXT
groupName: evolutions
advanced: true
- context: item
label: Health Now
name: health1
required: true
type: TEXT
groupName: values
- context: item
label: Health Today
name: between1
required: false
type: TEXT
groupName: values
advanced: true
- default: "1"
label: Forecast days (1-3)
name: forecastDays
required: true
type: INTEGER
groupName: values
advanced: true
- context: item
label: Health Tomorrow
name: health2
required: false
type: TEXT
groupName: values
advanced: true
- context: item
label: Health Day 3
name: health3
required: false
type: TEXT
groupName: values
advanced: true
- default: "100"
label: Maximum HP
name: max
required: false
type: DECIMAL
groupName: values
- context: item
label: Maximum HP Item
name: max_item
required: false
type: TEXT
groupName: values
advanced: true
- default: "#997700"
label: Forecast color
name: fc_color
required: false
type: TEXT
groupName: values
advanced: true
- label: Name
name: name
required: false
type: TEXT
- context: item
label: Switch Action
name: switchItem
required: false
type: TEXT
groupName: actions
- context: item
label: Actiongroup
name: groupName
required: false
type: TEXT
groupName: actions
- context: item
description: Direction in deg
label: Direction
name: direction
required: false
type: TEXT
groupName: windcircle
advanced: true
- default: "0"
description: Offset in deg
label: Direction offset
name: direction_offset
required: false
type: TEXT
groupName: windcircle
advanced: true
- default: "180"
label: Rotate arrow deg
name: rotateArrow
required: false
type: INTEGER
groupName: windcircle
advanced: true
- default: if:mdi:arrow-compass
label: Arrow image
name: arrowImage
required: false
type: TEXT
groupName: windcircle
advanced: true
- default: red
label: Arrow color
name: arrowColor
required: false
type: TEXT
groupName: windcircle
advanced: true
- default: 44px
label: Arrow height
name: arrowHeight
required: false
type: TEXT
groupName: windcircle
advanced: true
- default: 4px
label: Circle width
name: circleWidth
required: false
type: TEXT
groupName: windcircle
advanced: true
- default: "#34e5eb"
label: Circle color
name: circleColor
required: false
type: TEXT
groupName: windcircle
advanced: true
parameterGroups:
- name: values
label: Definitions of Health Values
description: what items shall define the health? Forecasts for more days possible
- name: evolutions
label: Evolutions and thresholds
description: what Pokemon shall be shown? Shall it evolve or change on thresholds?
- name: actions
label: Actions
description: what actions shall occur on tapping or holding the pokemon?
- name: windcircle
label: Wind Circle
description: style the wind direction circle
timestamp: Jul 8, 2025, 8:57:00 PM
component: f7-card
config:
stylesheet: |
:root {
--dark-color: #444; /* einheitlicher dunkler Farbton */
}
@keyframes showDay10 {
0% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes showDay5 {
0% { opacity: 1; }
25% { opacity: 0; }
100% { opacity: 0; }
}
.health-bar-bg {
position: absolute;
top: -6px;
left: 0;
width: 64px;
height: 8px;
background: var(--dark-color);
border-radius: 4px;
}
.health-bar-fill {
position: absolute;
top: -5px;
left: 1px;
max-width: 62px;
height: 6px;
background: #4caf50;
border-radius: 3px;
}
.value {
position: absolute;
top: 0px;
right: 0;
white-space: nowrap;
}
.name {
position: absolute;
top: -22px;
left: 0;
white-space: nowrap;
}
.container {
position: relative;
width: 64px;
height: 64px;
margin: 10px auto auto auto;
font-weight: 900;
font-size: smaller;
font-family: monospace;
text-shadow:
-1px -1px 0 var(--dark-color),
1px -1px 0 var(--dark-color),
-1px 1px 0 var(--dark-color),
1px 1px 0 var(--dark-color);
}
#sprite-viewport {
width: 64px;
height: 64px;
overflow: hidden;
position: relative;
}
.sprite {
position: absolute;
top: 0px;
left: 0;
width: 128px;
height: 64px;
background-position: left center;
background-size: contain;
animation: toggle-frame 1s infinite step-end;
}
@keyframes toggle-frame {
0% { left: 0; }
50% { left: -64px; }
100% { left: 0; }
}
slots:
default:
- component: f7-card-content
config: {}
slots:
default:
- component: div
config:
style:
aspect-ratio: 1/1
border-color: =props.circleColor
border-radius: 50%
border-style: solid
border-width: =props.circleWidth
margin: 14px auto
position: absolute
width: 60%
visible: |
=props.direction ? 'true' : 'false'
slots:
default:
- component: div
config:
style:
height: 100%
transform: ="rotate(" + (Number.parseFloat(@props.direction) +
Number.parseFloat(props.direction_offset)) + "deg)"
slots:
default:
- component: oh-icon
config:
height: =props.arrowHeight
icon: =props.arrowImage
style:
color: =props.arrowColor
transform: ="translate(0, -50%) rotate(" + Number.parseInt(props.rotateArrow)
+"deg)"
width: 100%
- component: div
config:
class:
- container
slots:
default:
- component: div
config:
class:
- health-bar-bg
- component: oh-repeater
config:
for: days
rangeStart: 1
rangeStop: |
=props.forecastDays ? Number(props.forecastDays) : 1
sourceType: range
slots:
default:
- component: div
config:
class:
- health-bar-fill
- ='day' + loop.days
style:
animation: "=(props.forecastDays > 1 && loop.days == 1) ? ('showDay10 20s
infinite steps(1)') : ''"
animation-delay: 0s
background-color: =props.fc_color
opacity: "=(props.forecastDays > 1 && loop.days > 1) ? '0' : '1'"
width: >
=(props['between' + loop.days] ? Math.max(
items[props['between' + loop.days]].displayState ?
(Number(items[props['between' + loop.days]].displayState.split(" ")[0].replace(',','.')) / (props.max_item ? Number(items[props.between].displayState.split(" ")[0].replace(',','.')) : props.max) * 100)
: (items[props.between].numericState / (props.max_item ? items[props.max_item].numericState : props.max) * 100)
,0
) : "0") + "%"
- component: div
config:
class:
- health-bar-fill
- ='day' + loop.days
style:
animation: "=(props.forecastDays > 1) ? ('showDay' + (loop.days == 1 ? '10' :
'5') + ' ' + ((props.forecastDays+1)*5) + 's
infinite steps(1)') : ''"
animation-delay: "=(loop.days == 1) ? '0s' : (loop.days*5) + 's'"
background-color: "=loop.days > 1 ? props.fc_color : ''"
opacity: "=props.forecastDays > 1 ? '0' : '1'"
width: >
=(props['health' + loop.days] ? Math.max(
items[props['health' + loop.days]].displayState ?
(Number(items[props['health' + loop.days]].displayState.split(" ")[0].replace(',','.')) / (props.max_item ? Number(items[props['health' + loop.days]].displayState.split(" ")[0].replace(',','.')) : props.max) * 100)
: (items[props['health' + loop.days]].numericState / (props.max_item ? items[props.max_item].numericState : props.max) * 100)
,0
) : "0") + "%"
- component: Label
config:
class:
- value
- ='day' + loop.days
style:
animation: "=(props.forecastDays > 1) ? ('showDay' + (loop.days == 1 ? '10' :
'5') + ' ' + ((props.forecastDays+1)*5) + 's
infinite steps(1)') : ''"
animation-delay: "=(loop.days == 1) ? '0s' : (loop.days*5) + 's'"
opacity: "=props.forecastDays > 1 ? '0' : '1'"
text: >
= props['health' + loop.days] ? (
(items[props['health' + loop.days]].displayState && items[props['health' + loop.days]].displayState.split(" ").length == 2 ?
items[props['health' + loop.days]].displayState.split(" ")[0].replace(',','.')
: items[props['health' + loop.days]].numericState ?
items[props['health' + loop.days]].numericState
: 0
)
+ "/" + (props.max_item ? items[props.max_item].numericState : Math.max(props.max,(items[props['health' + loop.days]].numericState ? items[props['health' + loop.days]].numericState : 0)))
+ " "
+(items[props['health' + loop.days]].displayState && items[props['health' + loop.days]].displayState.split(" ").length == 2 ? items[props['health' + loop.days]].displayState.split(" ")[1] : (
(items[props['health' + loop.days]].unit && items[props['health' + loop.days]].unit != "one" ? items[props['health' + loop.days]].unit : "")
))
) : ""
- component: Label
config:
class:
- name
- ='day' + loop.days
style:
animation: "=(props.forecastDays > 1) ? ('showDay' + (loop.days == 1 ? '10' :
'5') + ' ' + ((props.forecastDays+1)*5) + 's
infinite steps(1)') : ''"
animation-delay: "=(loop.days == 1) ? '0s' : (loop.days*5) + 's'"
opacity: "=props.forecastDays > 1 ? '0' : '1'"
text: >
= (props.name ? props.name : "") + (loop.days > 1 ?
' +' + (loop.days-1) + 'd' : '')
- component: oh-button
config:
action: "=(props.switchItem) ? 'toggle' : 'group'"
actionCommand: ON
actionCommandAlt: OFF
actionGroupPopupItem: "=props.groupName ? props.groupName : props.health1"
actionItem: =props.switchItem
id: sprite-viewport
taphold_action: group
taphold_actionGroupPopupItem: "=props.groupName ? props.groupName : props.health1"
slots:
default:
- component: div
config:
class:
- sprite
- ='pokemon' + (props.health)
style:
background-image: >
=(props.threshold3 &&
items[props.health1]?.numericState >=
props.threshold3) ? (
"url('" + props.base_path + props.pokemon3 + ".png')"
) : (
(props.threshold2 && items[props.health1]?.numericState >= props.threshold2) ?
"url('" + props.base_path + props.pokemon2 + ".png')"
: "url('" + props.base_path + props.pokemon + ".png')"
)
background-repeat: no-repeat
Pingback: Interaktiver Floorplan in OpenHAB mit Effekten