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:
GetDeviceInformationGetCapabilities/GetServicesGetServiceCapabilities
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
8899on Jooan, sometimes80,8080, or5000 - Profile token — usually
Profile_0(sometimesMainProfileor000) - 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_stopwithhost: "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.5pan/tilt values; valid range is-1.0to1.0. - Buttons feel too square → in the
gridblock, switchsquare: falsetosquare: true(or vice versa). - Layout too cramped → wrap multiple camera stacks in a
horizontal-stackfor 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.