Jooan PTZ cameras in Home Assistant – working solution when ONVIF integration fails

If you own a Jooan PTZ camera (or any of the budget Chinese IP cameras sold under the CloudEdge / Cam720 ecosystem) and you've tried to add it through Home Assistant's ONVIF integration only to be greeted with this error:

Failed to set up ONVIF device:
ActionNotSupported: <Element {http://www.w3.org/2003/05/soap-envelope}Detail ...>
(code:env:Receiver) (subcodes:{http://www.onvif.org/ver10/error}ActionNotSupported)

…this post is for you. Below is a complete working setup for live view and PTZ control directly from a Lovelace dashboard — no NVR or third-party bridge required.

I spent months looking for a clear, end-to-end solution and never found one in a single place, so I'm posting the full recipe here.


Why Home Assistant's ONVIF integration fails

Home Assistant's ONVIF integration uses the onvif-zeep-async Python library, which strictly follows the ONVIF specification. During the setup handshake it calls several mandatory methods in sequence:

  • GetDeviceInformation
  • GetCapabilities / GetServices
  • GetServiceCapabilities

Many budget Chinese cameras (Jooan, Sricam, generic CloudEdge models, etc.) ship with a stripped-down ONVIF implementation that supports streaming and PTZ commands but not the discovery / capabilities layer above them. As soon as even one mandatory call returns ActionNotSupported, the setup aborts.

Older tools like ONVIF Device Manager (ODM) work fine with these cameras because they're more permissive — they tolerate missing methods and just use what's available. We'll do the same: bypass HA's ONVIF integration entirely and call the PTZ SOAP methods directly via rest_command. That's the trick — the PTZ methods themselves are implemented, it's only the discovery layer that's broken.


What you need to gather first

Install ONVIF Device Manager (ODM) on Windows, or Onvier on Android. Point it at your camera and note down:

  • ONVIF port — often 8899 on Jooan, sometimes 80, 8080, or 5000
  • Profile token — usually Profile_0 (sometimes MainProfile or 000)
  • RTSP stream URL — e.g. rtsp://192.168.1.X:554/live/ch00_0
  • ONVIF username and password — set this in the camera's app (CloudEdge / Cam720). It is usually separate from your app login. If you've never set it, look for an "ONVIF user" or "Advanced device settings" option.

In the examples below I use these placeholders — replace with your own values:

Placeholder Meaning
192.168.1.X Your camera's IP
YOUR_USER ONVIF username
YOUR_PASS ONVIF password
Profile_0 Your camera's profile token
8899 Your camera's ONVIF port

Step 1 — configuration.yaml

Add the following blocks. The rest_command entries send raw SOAP envelopes to the camera; the script entries wrap them with an automatic stop so a single button press produces a short, controlled movement. Everything is parameterized by IP, so the exact same code works for any number of identical cameras.

yaml

rest_command:
  ptz_move:
    url: "http://{{ host }}:8899/onvif/ptz_service"
    method: POST
    content_type: "application/soap+xml; charset=utf-8"
    payload: >
      <?xml version="1.0" encoding="UTF-8"?>
      <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
                  xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl"
                  xmlns:tt="http://www.onvif.org/ver10/schema"
                  xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
        <s:Header>
          <wsse:Security>
            <wsse:UsernameToken>
              <wsse:Username>YOUR_USER</wsse:Username>
              <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">YOUR_PASS</wsse:Password>
            </wsse:UsernameToken>
          </wsse:Security>
        </s:Header>
        <s:Body>
          <tptz:ContinuousMove>
            <tptz:ProfileToken>Profile_0</tptz:ProfileToken>
            <tptz:Velocity>
              <tt:PanTilt x="{{ pan }}" y="{{ tilt }}"/>
            </tptz:Velocity>
          </tptz:ContinuousMove>
        </s:Body>
      </s:Envelope>

  ptz_stop:
    url: "http://{{ host }}:8899/onvif/ptz_service"
    method: POST
    content_type: "application/soap+xml; charset=utf-8"
    payload: >
      <?xml version="1.0" encoding="UTF-8"?>
      <s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
                  xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl"
                  xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
        <s:Header>
          <wsse:Security>
            <wsse:UsernameToken>
              <wsse:Username>YOUR_USER</wsse:Username>
              <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordText">YOUR_PASS</wsse:Password>
            </wsse:UsernameToken>
          </wsse:Security>
        </s:Header>
        <s:Body>
          <tptz:Stop>
            <tptz:ProfileToken>Profile_0</tptz:ProfileToken>
            <tptz:PanTilt>true</tptz:PanTilt>
          </tptz:Stop>
        </s:Body>
      </s:Envelope>

script:
  ptz_left:
    fields:
      host: { description: "Camera IP" }
    sequence:
      - action: rest_command.ptz_move
        data: { host: "{{ host }}", pan: -0.5, tilt: 0 }
      - delay: "00:00:00.5"
      - action: rest_command.ptz_stop
        data: { host: "{{ host }}" }
  ptz_right:
    fields:
      host: { description: "Camera IP" }
    sequence:
      - action: rest_command.ptz_move
        data: { host: "{{ host }}", pan: 0.5, tilt: 0 }
      - delay: "00:00:00.5"
      - action: rest_command.ptz_stop
        data: { host: "{{ host }}" }
  ptz_up:
    fields:
      host: { description: "Camera IP" }
    sequence:
      - action: rest_command.ptz_move
        data: { host: "{{ host }}", pan: 0, tilt: 0.5 }
      - delay: "00:00:00.5"
      - action: rest_command.ptz_stop
        data: { host: "{{ host }}" }
  ptz_down:
    fields:
      host: { description: "Camera IP" }
    sequence:
      - action: rest_command.ptz_move
        data: { host: "{{ host }}", pan: 0, tilt: -0.5 }
      - delay: "00:00:00.5"
      - action: rest_command.ptz_stop
        data: { host: "{{ host }}" }

Important: if your configuration.yaml already has a top-level rest_command: or script: key, do NOT add it a second time — just put the entries underneath the existing key. Each top-level key may only appear once in the file.

If you keep scripts in a separate scripts.yaml (default on HAOS installs), put the four ptz_* scripts there and omit the script: key from this block.

Then: Developer Tools → YAML → Check configuration, then Reload YAML or restart Home Assistant.


Step 2 — Quick sanity test

Before touching Lovelace, test the call from Developer Tools → Actions:

  • Action: rest_command.ptz_move
  • Data:

yaml

  host: "192.168.1.X"
  pan: 0.5
  tilt: 0
  • Hit "Perform action" — the camera should start panning right.
  • Then call rest_command.ptz_stop with host: "192.168.1.X" to stop it.

If this works, you're 90% done. If not, see troubleshooting at the bottom.


Step 3 — Lovelace card

This example uses the WebRTC Camera card by AlexxIT (HACS) for low-latency streaming, but any camera card works — only the first block of each vertical-stack needs adjusting.

yaml

type: vertical-stack
cards:
  - type: custom:webrtc-camera
    url: rtsp://YOUR_USER:[email protected]:554/live/ch00_0
    title: Camera 1
    muted: true
    ui: true
  - type: grid
    columns: 3
    square: false
    cards:
      - type: button
        show_icon: false
        show_name: false
        tap_action: { action: none }
      - type: button
        icon: mdi:arrow-up-bold
        show_name: false
        tap_action:
          action: call-service
          service: script.ptz_up
          data: { host: "192.168.1.X" }
      - type: button
        show_icon: false
        show_name: false
        tap_action: { action: none }
      - type: button
        icon: mdi:arrow-left-bold
        show_name: false
        tap_action:
          action: call-service
          service: script.ptz_left
          data: { host: "192.168.1.X" }
      - type: button
        icon: mdi:stop
        show_name: false
        tap_action:
          action: call-service
          service: rest_command.ptz_stop
          data: { host: "192.168.1.X" }
      - type: button
        icon: mdi:arrow-right-bold
        show_name: false
        tap_action:
          action: call-service
          service: script.ptz_right
          data: { host: "192.168.1.X" }
      - type: button
        show_icon: false
        show_name: false
        tap_action: { action: none }
      - type: button
        icon: mdi:arrow-down-bold
        show_name: false
        tap_action:
          action: call-service
          service: script.ptz_down
          data: { host: "192.168.1.X" }
      - type: button
        show_icon: false
        show_name: false
        tap_action: { action: none }

For multiple cameras, just duplicate the whole vertical-stack and change the IP in the url: line and in every host: line. The configuration.yaml never needs to grow — that's the entire point of parameterizing by IP.


Tweaking the feel

  • Movement steps too small / too big → change delay: "00:00:00.5" in the scripts (longer delay = bigger move per button press).
  • Movement too slow / too fast → change the 0.5 pan/tilt values; valid range is -1.0 to 1.0.
  • Buttons feel too square → in the grid block, switch square: false to square: true (or vice versa).
  • Layout too cramped → wrap multiple camera stacks in a horizontal-stack for side-by-side, or just leave them as a vertical column for full-width.

Troubleshooting

HTTP 401 Unauthorized Wrong credentials, OR the camera demands PasswordDigest instead of PasswordText. Most Jooan models accept PasswordText — if yours doesn't, the cleanest path is to enable a less-strict ONVIF user in the camera app rather than computing digests in YAML.

HTTP 400 or SOAP fault Wrong profile token. Use ODM to confirm the exact token name — values like MainProfile, mainStream, Profile_1, or 000 are common.

Connection refused / timeout Wrong ONVIF port. Run nmap 192.168.1.X from another machine to see all open ports; ONVIF usually responds on one of 80, 8080, 8899, 5000.

ODM also can't find the camera ONVIF isn't enabled in the camera firmware. Open the camera in the CloudEdge / Cam720 app, dig into Advanced settings, and look for "ONVIF". Some cheap models have it disabled by default; some have it stripped entirely.

Camera responds but never moves Some Jooan models accept the SOAP call but silently ignore it if PTZ is disabled in the app's user permissions. Check that the ONVIF user has "PTZ control" permission.


A note on security

The default admin / admin123 (or similar) credentials on many Jooan-class cameras are well-known and indexed by Shodan. Before deploying:

  • Set a strong, unique ONVIF password in the app.
  • Put your cameras on an isolated VLAN with no internet access if possible. These devices phone home aggressively, and their firmware patching record is… not great.
  • Block outbound internet at the router for the camera's IP if VLANs aren't an option.

Why this isn't in a HACS integration

Theoretically, the HA ONVIF integration could be patched to tolerate missing methods and fall back to direct PTZ calls, and this has been discussed on the HA GitHub repeatedly. The maintainers (reasonably) prefer to keep it spec-compliant. A dedicated "lenient ONVIF" custom integration would be useful, but until someone writes one, the rest_command approach above is the simplest, most transparent fix.

If this saved you a weekend, please leave a reply with your model number so others can find the thread when they search.

1 Like