Rollershutter with Zigbee2MQTT – How to send proper JSON?

Hi everyone,

I’m currently transitioning from deCONZ to Zigbee2MQTT and have set up my rollershutter in OpenHAB.
Here is my current Thing configuration:

Thing mqtt:topic:zigbee (mqtt:broker:mosquitto) {
    Channels:
	Type rollershutter : rollershuttersss "rollershutter" [
		stateTopic="zigbee2mqtt/0xa4c1384c85d32598",
		transformationPattern="JSONPATH:$.position",
		commandTopic="zigbee2mqtt/0xa4c1384c85d32598/set",
		stop= "STOP",
      	formatBeforePublish= "{\"state\":%s}",
      	off= "CLOSE",
      	on= "OPEN"
       ]
}

The MQTT topic currently provides:

zigbee2mqtt/0xa4c1384c85d32598
{
  "backlight_mode":"ON",
  "calibration":"OFF",
  "calibration_time":16.5,
  "indicator_mode":"on",
  "linkquality":255,
  "motor_reversal":"OFF",
  "moving":"STOP",
  "position":100,
  "state":"OPEN"
}

Problem:
When I operate the rollershutter in OpenHAB, only the number is sent, e.g.:

zigbee2mqtt/0xa4c1384c85d32598/set
OPEN/CLOSE/STOP

However, I want to send JSON like this:

For Open/Close/Stop:

{"state":"OPEN"}
{"state":"CLOSE"}
{"state":"STOP"}

Here is also the part of the docs:

Cover
The current state of this cover is in the published state under the state property (value is OPEN or CLOSE). To control this cover publish a message to topic zigbee2mqtt/FRIENDLY_NAME/set with payload {"state": "OPEN"}, {"state": "CLOSE"}, {"state": "STOP"}. It's not possible to read (/get) this value. To change the position publish a message to topic zigbee2mqtt/FRIENDLY_NAME/set with payload {"position": VALUE} where VALUE is a number between 0 and 100.

My questions for the community:

  1. How can I configure the rollershutter channel correctly so that formatBeforePublish works and the JSON is sent?
  2. Or is there another recommended approach in OpenHAB for controlling Zigbee2MQTT rollershutters?

Thanks in advance for your help!

Here is a sample of my roller shutter blind set up:

UID: mqtt:topic:mqttbroker:2910e769b4
label: Blinds back 1-2
thingTypeUID: mqtt:topic
configuration:
  payloadNotAvailable: online
  transformationPattern:
    - JSONPATH:$state
  payloadAvailable: offline
bridgeUID: mqtt:broker:mqttbroker
location: House
channels:
  - id: blind1backcontrol
    channelTypeUID: mqtt:rollershutter
    label: Blind 1 back control
    configuration:
      invert: true
      stop: STOP
      commandTopic: zigbee2mqtt/blind1back/set
      stateTopic: zigbee2mqtt/blind1back
      transformationPattern:
        - JSONPATH:$.position
      off: CLOSE
      on: OPEN
  - id: blind1backposition
    channelTypeUID: mqtt:number
    label: Blind 1 back position
    configuration:
      commandTopic: zigbee2mqtt/blind1back/set/position
      min: 0
      stateTopic: zigbee2mqtt/blind1back
      transformationPattern:
        - JSONPATH:$.position
      max: 100
  - id: blind1backstatus
    channelTypeUID: mqtt:contact
    label: Blind 1 back status regex
    description: Put regex as STOP command was logging errors
    configuration:
      stateTopic: zigbee2mqtt/blind1back
      transformationPattern:
        - REGEX:(^((?!STOP).)*$)∩JSONPATH:$.state
      off: CLOSE
      on: OPEN

Screenshot from 2025-10-06 20-03-48
Screenshot from 2025-10-06 20-03-01



I user a widget to control the blinds.

uid: Remote_curtain_control_notimer_counter
tags: []
props:
  parameters:
    - context: Text
      description: Title of the card
      label: Title
      name: rollerTitle
      required: false
      type: TEXT
    - context: item
      description: Rollershutter Item
      label: Rollershutter Item
      name: groupItem
      required: true
      type: TEXT
    - context: item
      description: Rollershutter position
      label: Rollershutter Position
      name: blindposition
      required: true
      type: TEXT
    - context: item
      description: Rollershutter position counter
      label: Rollershutter position counter
      name: closedcounter
      required: true
      type: TEXT
timestamp: Jul 18, 2025, 9:43:38 AM
component: f7-card
config:
  style:
    background: '=themeOptions.dark === "dark" ? "rgb(40, 40, 40)" : "rgb(255, 255, 210)"'
    border-radius: var(--f7-card-expandable-border-radius)
    box-shadow: 5px 5px 15px 1px rgba(0,0,0,0.3)
    margin-left: 5pxcon
    margin-right: 5px
slots:
  default:
    - component: f7-card-content
      config:
        style:
          position: relative
          z-index: 3
      slots:
        default:
          - component: f7-row
            config:
              style:
                margin-bottom: -10px
                margin-left: 0px
                margin-right: 0px
            slots:
              default:
                - component: f7-col
                  config:
                    style:
                      margin-left: 0px
                      margin-right: 0px
                      width: 60px
                  slots:
                    default:
                      - component: oh-icon
                        config:
                          height: 50px
                          icon: =(items[props.blindposition].state >
                            89)?('blinds-90'):(items[props.blindposition].state
                            >
                            79)?('blinds-80'):(items[props.blindposition].state
                            >
                            69)?('blinds-70'):(items[props.blindposition].state
                            >
                            59)?('blinds-60'):(items[props.blindposition].state
                            >
                            49)?('blinds-50'):(items[props.blindposition].state
                            >
                            39)?('blinds-40'):(items[props.blindposition].state
                            >
                            29)?('blinds-30'):(items[props.blindposition].state
                            >
                            19)?('blinds-20'):(items[props.blindposition].state
                            > 9)?('oh:blinds-10'):('oh:blinds-0')
                          style:
                            color: orange
                - component: f7-col
                  config:
                    style:
                      margin-left: 60px
                      margin-right: 0px
                      position: absolute
                      width: 80%
                  slots:
                    default:
                      - component: f7-row
                        slots:
                          default:
                            - component: Label
                              config:
                                style:
                                  color: '=themeOptions.dark === "dark" ? "white" : "black"'
                                  font-size: 15px
                                  font-weight: 500
                                text: =props.rollerTitle
                      - component: f7-row
                        config:
                          style:
                            align-items: right
                            display: flex
                            flex-direction: row
                        slots:
                          default:
                            - component: f7-chip
                              config:
                                style:
                                  background: =Math.round(items[props.blindposition].state)=="0"?"Green":"orange"
                                  color: black
                                  font-weight: 500
                                text: '=Math.round(items[props.blindposition].state) == "100" ? "Open" :
                                  Math.round(items[props.groupItem].state) ==
                                  "100" ? "Closed" :
                                  Math.round(items[props.blindposition].state)  +
                                  "% open"'
                      - component: f7-row
                        slots:
                          default:
                            - component: Label
                              config:
                                style:
                                  color: '=themeOptions.dark === "dark" ? "white" : "black"'
                                  font-size: 15px
                                  font-weight: 500
                                text: '="Shut: " + Math.round(items[props.closedcounter].state) + " times"'
                - component: f7-col
                  config:
                    style:
                      margin-left: 0px
                      margin-right: 0px
                      width: 90px
                  slots:
                    default:
                      - component: oh-link
                        config:
                          action: command
                          actionCommand: UP
                          actionFeedback: UP pressed
                          actionItem: =props.groupItem
                          iconColor: orange
                          iconF7: arrow_left_circle
                          iconSize: 28
                          style:
                            background: '=themeOptions.dark === "dark" ? "rgb(40, 40, 40)" : "rgb(240, 240,
                              240)"'
                            z-index: 98
                      - component: oh-link
                        config:
                          action: command
                          actionCommand: STOP
                          actionFeedback: STOP pressed
                          actionItem: =props.groupItem
                          iconF7: stop_circle
                          iconSize: 28
                          style:
                            background: '=themeOptions.dark === "dark" ? "rgb(40, 40, 40)" : "rgb(240, 240,
                              240)"'
                            z-index: 98
                      - component: oh-link
                        config:
                          action: command
                          actionCommand: DOWN
                          actionFeedback: DOWN pressed
                          actionItem: =props.groupItem
                          iconColor: orange
                          iconF7: arrow_right_circle
                          iconSize: 28
                          style:
                            background: '=themeOptions.dark === "dark" ? "rgb(40, 40, 40)" : "rgb(240, 240,
                              240)"'
                            z-index: 98
    - component: f7-card-content
      config:
        class: align-items-center alignt-text-center justify-content-center
        style:
          margin-left: 15px
          margin-right: 15px
          position: relative
          z-index: 2
        visible: =(vars.PresetVisible) == true
      slots:
        default:
          - component: f7-row
            slots:
              default:
                - component: oh-slider
                  config:
                    color: orange
                    item: =props.blindposition
                    max: 100
                    min: 0
                    step: "1"
                    style:
                      background: trasparent
                      color: black
                      font-size: 13px
          - component: f7-row
            config:
              style:
                margin-top: 15px
            slots:
              default:
                - component: oh-button
                  config:
                    action: command
                    actionCommand: 25
                    actionItem: =props.blindposition
                    round: true
                    style:
                      background: '=items[props.groupItem].state >= "20" &&
                        items[props.groupItem].state <= "30" ? "orange" :
                        "rgb(210, 210, 210)"'
                      color: black
                      z-index: 98
                    text: 25
                - component: oh-button
                  config:
                    action: command
                    actionCommand: 40
                    actionItem: =props.blindposition
                    round: true
                    style:
                      background: '=items[props.groupItem].state >= "35" &&
                        items[props.groupItem].state <= "45" ? "orange" :
                        "rgb(210, 210, 210)"'
                      color: black
                      z-index: 98
                    text: 40
                - component: oh-button
                  config:
                    action: command
                    actionCommand: 50
                    actionItem: =props.blindposition
                    round: true
                    style:
                      background: '=items[props.groupItem].state >= "45" &&
                        items[props.groupItem].state <= "55" ? "orange" :
                        "rgb(210, 210, 210)"'
                      color: black
                      z-index: 98
                    text: 50
                - component: oh-button
                  config:
                    action: command
                    actionCommand: 75
                    actionItem: =props.blindposition
                    round: true
                    style:
                      background: '=items[props.groupItem].state >= "70" &&
                        items[props.groupItem].state <= "80" ? "orange" :
                        "rgb(210, 210, 210)"'
                      color: black
                      z-index: 98
                    text: 75
                - component: oh-button
                  config:
                    action: command
                    actionCommand: 90
                    actionItem: =props.blindposition
                    round: true
                    style:
                      background: '=items[props.groupItem].state >= "85" &&
                        items[props.groupItem].state <= "95" ? "orange" :
                        "rgb(210, 210, 210)"'
                      color: black
                      z-index: 98
                    text: 90
    - component: f7-card-content
      config:
        class: align-items-center alignt-text-center justify-content-center
        style:
          margin-bottom: -50px
          margin-left: 15px
          margin-right: 15px
          margin-top: -50px
          position: relative
          z-index: 0
        visible: =(vars.ChartVisible) == true
      slots:
        default:
          - component: oh-chart
            config:
              chartType: day
              options:
                backgroundColor: transparent
              periodVisible: false
            slots:
              grid:
                - component: oh-chart-grid
                  config:
                    containLabel: false
              series:
                - component: oh-time-series
                  config:
                    barWidth: 1
                    color: orange
                    gridIndex: 0
                    item: =props.blindposition
                    type: line
                    xAxisIndex: 0
                    yAxisIndex: 0
              xAxis:
                - component: oh-time-axis
                  config:
                    axisPointer:
                      show: false
                    gridIndex: 0
              yAxis:
                - component: oh-value-axis
                  config:
                    gridIndex: 0
                    min: 0
                    name: "  % open"
    - component: f7-card-footer
      config:
        style:
          background: rgb(245, 218, 137)
          border-radius: 0 0 10px 10px
          height: auto
      slots:
        default:
          - component: f7-block
            config:
              style:
                bottom: 3px
                display: flex
                left: 0
                position: absolute
            slots:
              default:
                - component: oh-button
                  config:
                    action: variable
                    actionVariable: PresetVisible
                    actionVariableValue: =! (vars.PresetVisible)
                  slots:
                    default:
                      - component: oh-icon
                        config:
                          icon: iconify:mdi:window-shutter-auto
                          style:
                            color: "=(vars.PresetVisible ? 'rgb(255, 128, 0)' : 'rgb(106, 106, 106)')"
                          width: 25px
                - component: oh-button
                  config:
                    action: variable
                    actionVariable: ChartVisible
                    actionVariableValue: =! (vars.ChartVisible)
                  slots:
                    default:
                      - component: oh-icon
                        config:
                          icon: iconify:ant-design:bar-chart-outlined
                          style:
                            color: "=(vars.ChartVisible ? 'rgb(255, 128, 0)' : 'rgb(106, 106, 106)')"
                          width: 25px

Also if you use the zigbee2mqtt frontend/web you can easliy set friendly names:

frontend:
  enabled: true
  port: 8082
  host: 0.0.0.0

Good luck. Hope some of that helps.
zigbee2mqtt is good once you get used to it. I am using a docker version of it.

@ubeaut thanks for your config but I need this json to be send

{"state":"OPEN"}

And not only “OPEN”…

If I create a simple switch

- id: lkjh
    channelTypeUID: mqtt:switch
    label: On/Off Switch
    description: ""
    configuration:
      formatBeforePublish: '{"state" :%s}'
      commandTopic: test
      stateTopic: test

the transformation running fine I will open a bug report on github

work around

- id: rollershuttert
    channelTypeUID: mqtt:rollershutter
    label: Rollershutter
    description: ""
    configuration:
      commandTopic: zigbee2mqtt/0xa4c1384c85d32598/set
      stop: '{"state":"STOP"}'
      formatBeforePublish: '{\"state\":%s}'
      stateTopic: zigbee2mqtt/0xa4c1384c85d32598
      off: '{"state":"CLOSE"}'
      on: '{"state":"OPEN"}