Startseite » Pokémon Widget für OpenHAB

Pokémon Widget für OpenHAB

Eines meiner liebsten Spielereien in meinem Pokémon-Style-Floorplan: ein Widget mit echter Health-Bar-Optik, das Werte für heute, morgen und übermorgen anzeigt, per Klick Aktionen auslöst und sogar einen animierten Windkreis für die Windrichtung mitbringt. Hier zeige ich dir den kompletten Aufbau zum Nachbauen.

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.
  • 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 und showDay5 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 dem direction-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 mit sprite):
    • 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).
    • Das Ganze steckt in einem Button → dadurch kann man beim Antippen Aktionen auslösen (Schalter toggeln oder Gruppen-Popup öffnen).
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

0
0

2 Gedanken zu „Pokémon Widget für OpenHAB“

  1. Pingback: Interaktiver Floorplan in OpenHAB mit Effekten

  2. Pingback: Interactive Floorplan in OpenHAB with Effects

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre, wie deine Kommentardaten verarbeitet werden.

Translate »