mirror of
https://github.com/kerberos-io/onvif.git
synced 2026-08-23 15:08:33 +00:00
Future per-call knobs (timeout, context, custom namespaces) will arrive sooner than later. Adding them as SendSoapWithHeader2 / 3 / N would turn the Device API into a combinatorial mess; adding them as new required positional args breaks every existing consumer. SendSoapWithOptions accepts variadic SoapOption values. SendSoap and SendSoapWithHeader become one-line delegates so all current callers keep their signatures, and the public surface is purely additive. Only WithHeader ships today — wiring the AXIS ReferenceParameters path through the same plumbing. New options land as WithX constructors in this file rather than as new Device methods.
540 lines
17 KiB
Go
540 lines
17 KiB
Go
package onvif
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/kerberos-io/onvif/networking"
|
|
"github.com/kerberos-io/onvif/xsd/onvif"
|
|
|
|
"github.com/beevik/etree"
|
|
"github.com/kerberos-io/onvif/device"
|
|
"github.com/kerberos-io/onvif/gosoap"
|
|
)
|
|
|
|
// Xlmns XML Scheam
|
|
var Xlmns = map[string]string{
|
|
"onvif": "http://www.onvif.org/ver10/schema",
|
|
"tds": "http://www.onvif.org/ver10/device/wsdl",
|
|
"trt": "http://www.onvif.org/ver10/media/wsdl",
|
|
"tr2": "http://www.onvif.org/ver20/media/wsdl",
|
|
"tev": "http://www.onvif.org/ver10/events/wsdl",
|
|
"tptz": "http://www.onvif.org/ver20/ptz/wsdl",
|
|
"timg": "http://www.onvif.org/ver20/imaging/wsdl",
|
|
"tan": "http://www.onvif.org/ver20/analytics/wsdl",
|
|
"xmime": "http://www.w3.org/2005/05/xmlmime",
|
|
"wsnt": "http://docs.oasis-open.org/wsn/b-2",
|
|
"xop": "http://www.w3.org/2004/08/xop/include",
|
|
"wsa": "http://www.w3.org/2005/08/addressing",
|
|
"wstop": "http://docs.oasis-open.org/wsn/t-1",
|
|
"wsntw": "http://docs.oasis-open.org/wsn/bw-2",
|
|
"wsrf-rw": "http://docs.oasis-open.org/wsrf/rw-2",
|
|
"wsaw": "http://www.w3.org/2006/05/addressing/wsdl",
|
|
"tt": "http://www.onvif.org/ver10/recording/wsdl",
|
|
"wsse": "http://docs.oasis-open.org/wss/2004/01/oasis-200401",
|
|
"wsu": "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd",
|
|
}
|
|
|
|
// DeviceType alias for int
|
|
type DeviceType int
|
|
|
|
// Onvif Device Tyoe
|
|
const (
|
|
NVD DeviceType = iota
|
|
NVS
|
|
NVA
|
|
NVT
|
|
|
|
ContentType = "Content-Type"
|
|
)
|
|
|
|
func (devType DeviceType) String() string {
|
|
stringRepresentation := []string{
|
|
"NetworkVideoDisplay",
|
|
"NetworkVideoStorage",
|
|
"NetworkVideoAnalytics",
|
|
"NetworkVideoTransmitter",
|
|
}
|
|
i := uint8(devType)
|
|
switch {
|
|
case i <= uint8(NVT):
|
|
return stringRepresentation[i]
|
|
default:
|
|
return strconv.Itoa(int(i))
|
|
}
|
|
}
|
|
|
|
// DeviceInfo struct contains general information about ONVIF device
|
|
type DeviceInfo struct {
|
|
Name string
|
|
Manufacturer string
|
|
Model string
|
|
FirmwareVersion string
|
|
SerialNumber string
|
|
HardwareId string
|
|
}
|
|
|
|
// Device for a new device of onvif and DeviceInfo
|
|
// struct represents an abstract ONVIF device.
|
|
// It contains methods, which helps to communicate with ONVIF device
|
|
type Device struct {
|
|
params DeviceParams
|
|
endpoints map[string]string
|
|
info DeviceInfo
|
|
digestClient *DigestClient
|
|
}
|
|
|
|
type DeviceParams struct {
|
|
Xaddr string
|
|
EndpointRefAddress string
|
|
Username string
|
|
Password string
|
|
HttpClient *http.Client
|
|
AuthMode string
|
|
}
|
|
|
|
// GetServices return available endpoints
|
|
func (dev *Device) GetServices() map[string]string {
|
|
return dev.endpoints
|
|
}
|
|
|
|
// GetServices return available endpoints
|
|
func (dev *Device) GetDeviceInfo() DeviceInfo {
|
|
return dev.info
|
|
}
|
|
|
|
// SetDeviceInfoFromScopes goes through the scopes and sets the device info fields for supported categories (currently name and hardware).
|
|
// See 7.3.2.2 Scopes in the ONVIF Core Specification (https://www.onvif.org/specs/core/ONVIF-Core-Specification.pdf).
|
|
func (dev *Device) SetDeviceInfoFromScopes(scopes []string) {
|
|
newInfo := dev.info
|
|
supportedScopes := []struct {
|
|
category string
|
|
setField func(s string)
|
|
}{
|
|
{category: "name", setField: func(s string) { newInfo.Name = s }},
|
|
{category: "hardware", setField: func(s string) { newInfo.Model = s }},
|
|
}
|
|
|
|
for _, s := range scopes {
|
|
for _, supp := range supportedScopes {
|
|
fullScope := fmt.Sprintf("onvif://www.onvif.org/%s/", supp.category)
|
|
scopeValue, matchesScope := strings.CutPrefix(s, fullScope)
|
|
if matchesScope {
|
|
unescaped, err := url.QueryUnescape(scopeValue)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
supp.setField(unescaped)
|
|
}
|
|
}
|
|
}
|
|
dev.info = newInfo
|
|
}
|
|
|
|
func readResponse(resp *http.Response) string {
|
|
b, err := ioutil.ReadAll(resp.Body)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func (dev *Device) getSupportedServices(resp *http.Response) {
|
|
doc := etree.NewDocument()
|
|
|
|
data, _ := ioutil.ReadAll(resp.Body)
|
|
|
|
if err := doc.ReadFromBytes(data); err != nil {
|
|
//log.Println(err.Error())
|
|
return
|
|
}
|
|
services := doc.FindElements("./Envelope/Body/GetCapabilitiesResponse/Capabilities/*/XAddr")
|
|
for _, j := range services {
|
|
dev.addEndpoint(j.Parent().Tag, j.Text())
|
|
}
|
|
|
|
extensionServices := doc.FindElements("./Envelope/Body/GetCapabilitiesResponse/Capabilities/Extension/*/XAddr")
|
|
for _, j := range extensionServices {
|
|
dev.addEndpoint(j.Parent().Tag, j.Text())
|
|
}
|
|
}
|
|
|
|
// NewDevice function construct a ONVIF Device entity
|
|
func NewDevice(params DeviceParams) (*Device, error) {
|
|
dev := new(Device)
|
|
dev.params = params
|
|
dev.endpoints = make(map[string]string)
|
|
dev.addEndpoint("Device", "http://"+dev.params.Xaddr+"/onvif/device_service")
|
|
|
|
if dev.params.HttpClient == nil {
|
|
dev.params.HttpClient = new(http.Client)
|
|
}
|
|
dev.digestClient = NewDigestClient(dev.params.HttpClient, dev.params.Username, dev.params.Password)
|
|
|
|
getCapabilities := device.GetCapabilities{Category: []onvif.CapabilityCategory{"All"}}
|
|
|
|
resp, err := dev.CallMethod(getCapabilities)
|
|
|
|
if err != nil || resp.StatusCode != http.StatusOK {
|
|
return nil, errors.New("camera is not available at " + dev.params.Xaddr + " or it does not support ONVIF services")
|
|
}
|
|
|
|
dev.getSupportedServices(resp)
|
|
return dev, nil
|
|
}
|
|
|
|
func (dev *Device) addEndpoint(Key, Value string) {
|
|
//use lowCaseKey
|
|
//make key having ability to handle Mixed Case for Different vendor devcie (e.g. Events EVENTS, events)
|
|
lowCaseKey := strings.ToLower(Key)
|
|
|
|
// Replace host with host from device params.
|
|
if u, err := url.Parse(Value); err == nil {
|
|
u.Host = dev.params.Xaddr
|
|
Value = u.String()
|
|
}
|
|
|
|
dev.endpoints[lowCaseKey] = Value
|
|
|
|
if lowCaseKey == strings.ToLower(MediaWebService) {
|
|
// Media2 uses the same endpoint but different XML name space
|
|
dev.endpoints[strings.ToLower(Media2WebService)] = Value
|
|
}
|
|
}
|
|
|
|
// GetEndpoint returns specific ONVIF service endpoint address
|
|
func (dev *Device) GetEndpoint(name string) string {
|
|
return dev.endpoints[name]
|
|
}
|
|
|
|
func (dev *Device) buildMethodSOAP(msg string) (gosoap.SoapMessage, error) {
|
|
doc := etree.NewDocument()
|
|
if err := doc.ReadFromString(msg); err != nil {
|
|
//log.Println("Got error")
|
|
|
|
return "", err
|
|
}
|
|
element := doc.Root()
|
|
|
|
soap := gosoap.NewEmptySOAP()
|
|
soap.AddBodyContent(element)
|
|
|
|
return soap, nil
|
|
}
|
|
|
|
// getEndpoint functions get the target service endpoint in a better way
|
|
func (dev *Device) getEndpoint(endpoint string) (string, error) {
|
|
|
|
// common condition, endpointMark in map we use this.
|
|
if endpointURL, bFound := dev.endpoints[endpoint]; bFound {
|
|
return endpointURL, nil
|
|
}
|
|
|
|
//but ,if we have endpoint like event、analytic
|
|
//and sametime the Targetkey like : events、analytics
|
|
//we use fuzzy way to find the best match url
|
|
var endpointURL string
|
|
for targetKey := range dev.endpoints {
|
|
if strings.Contains(targetKey, endpoint) {
|
|
endpointURL = dev.endpoints[targetKey]
|
|
return endpointURL, nil
|
|
}
|
|
}
|
|
return endpointURL, errors.New("target endpoint service not found")
|
|
}
|
|
|
|
// CallMethod functions call an method, defined <method> struct.
|
|
// You should use Authenticate method to call authorized requests.
|
|
func (dev Device) CallMethod(method interface{}) (*http.Response, error) {
|
|
pkgPath := strings.Split(reflect.TypeOf(method).PkgPath(), "/")
|
|
pkg := strings.ToLower(pkgPath[len(pkgPath)-1])
|
|
|
|
endpoint, err := dev.getEndpoint(pkg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return dev.callMethodDo(endpoint, method)
|
|
}
|
|
|
|
// CallMethod functions call an method, defined <method> struct with authentication data
|
|
func (dev Device) callMethodDo(endpoint string, method interface{}) (*http.Response, error) {
|
|
output, err := xml.MarshalIndent(method, " ", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
soap, err := dev.buildMethodSOAP(string(output))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
soap.AddRootNamespaces(Xlmns)
|
|
soap.AddAction()
|
|
|
|
//Auth Handling
|
|
if dev.params.Username != "" && dev.params.Password != "" {
|
|
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
|
}
|
|
|
|
servResp, err := networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
|
|
if err != nil {
|
|
// Close server response body to reuse the connection
|
|
if servResp != nil {
|
|
servResp.Body.Close()
|
|
}
|
|
servResp, err = networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
|
|
}
|
|
|
|
return servResp, err
|
|
}
|
|
|
|
func (dev *Device) GetDeviceParams() DeviceParams {
|
|
return dev.params
|
|
}
|
|
|
|
func (dev *Device) GetEndpointByRequestStruct(requestStruct interface{}) (string, error) {
|
|
pkgPath := strings.Split(reflect.TypeOf(requestStruct).Elem().PkgPath(), "/")
|
|
pkg := strings.ToLower(pkgPath[len(pkgPath)-1])
|
|
|
|
endpoint, err := dev.getEndpoint(pkg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return endpoint, err
|
|
}
|
|
|
|
/*func (dev *Device) SendSoap(endpoint string, xmlRequestBody string) (resp *http.Response, err error) {
|
|
soap := gosoap.NewEmptySOAP()
|
|
soap.AddStringBodyContent(xmlRequestBody)
|
|
soap.AddRootNamespaces(Xlmns)
|
|
if dev.params.AuthMode == UsernameTokenAuth || dev.params.AuthMode == Both {
|
|
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
|
}
|
|
|
|
if dev.params.AuthMode == DigestAuth || dev.params.AuthMode == Both {
|
|
resp, err = dev.digestClient.Do(http.MethodPost, endpoint, soap.String())
|
|
} else {
|
|
var req *http.Request
|
|
req, err = createHttpRequest(http.MethodPost, endpoint, soap.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err = dev.params.HttpClient.Do(req)
|
|
}
|
|
return resp, err
|
|
}*/
|
|
|
|
// SendSoap POSTs the given body wrapped in a SOAP envelope.
|
|
func (dev Device) SendSoap(endpoint string, xmlRequestBody string) (*http.Response, error) {
|
|
return dev.SendSoapWithOptions(endpoint, xmlRequestBody)
|
|
}
|
|
|
|
// SendSoapWithHeader is SendSoap plus arbitrary inner-Header XML —
|
|
// needed to echo WS-Addressing ReferenceParameters (with
|
|
// wsa:IsReferenceParameter="true") back to vendors like AXIS that
|
|
// identify pull-point subscriptions through them rather than the URL.
|
|
//
|
|
// xmlHeaderContent must be well-formed XML representing zero or more
|
|
// SOAP Header child elements (sibling top-level elements are
|
|
// supported; the spec lets each reference parameter be its own header
|
|
// block). The caller is responsible for escaping any externally
|
|
// sourced data inside it. Malformed XML returns an error before any
|
|
// request is made.
|
|
func (dev Device) SendSoapWithHeader(endpoint, xmlRequestBody, xmlHeaderContent string) (*http.Response, error) {
|
|
return dev.SendSoapWithOptions(endpoint, xmlRequestBody, WithHeader(xmlHeaderContent))
|
|
}
|
|
|
|
// SoapOption tweaks a single SendSoapWithOptions call. New options
|
|
// (per-call timeout, context, custom envelope namespaces, ...) should
|
|
// be added as WithX constructors here rather than as new method
|
|
// variants on Device.
|
|
type SoapOption func(*soapConfig)
|
|
|
|
type soapConfig struct {
|
|
headerContent string
|
|
}
|
|
|
|
// WithHeader adds inner-Header XML to the envelope. See
|
|
// SendSoapWithHeader for the content contract.
|
|
func WithHeader(xml string) SoapOption {
|
|
return func(c *soapConfig) { c.headerContent = xml }
|
|
}
|
|
|
|
// SendSoapWithOptions is the workhorse behind SendSoap and
|
|
// SendSoapWithHeader; call it directly when you need to combine
|
|
// options or pass options not surfaced by the convenience wrappers.
|
|
func (dev Device) SendSoapWithOptions(endpoint, xmlRequestBody string, opts ...SoapOption) (*http.Response, error) {
|
|
var cfg soapConfig
|
|
for _, o := range opts {
|
|
o(&cfg)
|
|
}
|
|
soap := gosoap.NewEmptySOAP()
|
|
soap.AddStringBodyContent(xmlRequestBody)
|
|
soap.AddRootNamespaces(Xlmns)
|
|
soap.AddAction()
|
|
if err := addHeaderChildren(&soap, cfg.headerContent); err != nil {
|
|
return nil, err
|
|
}
|
|
if dev.params.Username != "" && dev.params.Password != "" {
|
|
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
|
}
|
|
|
|
servResp, err := networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
|
|
if err != nil {
|
|
if servResp != nil {
|
|
servResp.Body.Close()
|
|
}
|
|
servResp, err = networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
|
|
}
|
|
return servResp, err
|
|
}
|
|
|
|
// addHeaderChildren wraps the fragment so etree can parse multi-root
|
|
// XML, then adds each top-level child as its own SOAP Header block.
|
|
// gosoap.AddStringHeaderContent only accepts a single root element.
|
|
func addHeaderChildren(soap *gosoap.SoapMessage, xmlHeaderContent string) error {
|
|
if xmlHeaderContent == "" {
|
|
return nil
|
|
}
|
|
doc := etree.NewDocument()
|
|
if err := doc.ReadFromString("<wrap>" + xmlHeaderContent + "</wrap>"); err != nil {
|
|
return fmt.Errorf("parse header content: %w", err)
|
|
}
|
|
wrap := doc.SelectElement("wrap")
|
|
if wrap == nil {
|
|
return errors.New("parse header content: missing wrap root")
|
|
}
|
|
for _, child := range wrap.ChildElements() {
|
|
d := etree.NewDocument()
|
|
d.SetRoot(child.Copy())
|
|
s, err := d.WriteToString()
|
|
if err != nil {
|
|
return fmt.Errorf("serialise header child: %w", err)
|
|
}
|
|
if err := soap.AddStringHeaderContent(s); err != nil {
|
|
return fmt.Errorf("add header child: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func createHttpRequest(httpMethod string, endpoint string, soap string) (req *http.Request, err error) {
|
|
req, err = http.NewRequest(httpMethod, endpoint, bytes.NewBufferString(soap))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set(ContentType, "application/soap+xml; charset=utf-8")
|
|
return req, nil
|
|
}
|
|
|
|
func (dev *Device) CallOnvifFunction(serviceName, functionName string, data []byte) (interface{}, error) {
|
|
function, err := FunctionByServiceAndFunctionName(serviceName, functionName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request, err := createRequest(function, data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fail to create '%s' request for the web service '%s', %v", functionName, serviceName, err)
|
|
}
|
|
|
|
endpoint, err := dev.GetEndpointByRequestStruct(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
requestBody, err := xml.Marshal(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
xmlRequestBody := string(requestBody)
|
|
|
|
servResp, err := dev.SendSoap(endpoint, xmlRequestBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fail to send the '%s' request for the web service '%s', %v", functionName, serviceName, err)
|
|
}
|
|
defer servResp.Body.Close()
|
|
|
|
rsp, err := ioutil.ReadAll(servResp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
responseEnvelope, err := createResponse(function, rsp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fail to create '%s' response for the web service '%s', %v", functionName, serviceName, err)
|
|
}
|
|
|
|
if servResp.StatusCode == http.StatusUnauthorized {
|
|
return nil, fmt.Errorf("fail to verify the authentication for the function '%s' of web service '%s'. Onvif error: %s",
|
|
functionName, serviceName, responseEnvelope.Body.Fault.String())
|
|
} else if servResp.StatusCode == http.StatusBadRequest {
|
|
return nil, fmt.Errorf("invalid request for the function '%s' of web service '%s'. Onvif error: %s",
|
|
functionName, serviceName, responseEnvelope.Body.Fault.String())
|
|
} else if servResp.StatusCode > http.StatusNoContent {
|
|
return nil, fmt.Errorf("fail to execute the request for the function '%s' of web service '%s'. Onvif error: %s",
|
|
functionName, serviceName, responseEnvelope.Body.Fault.String())
|
|
}
|
|
return responseEnvelope.Body.Content, nil
|
|
}
|
|
|
|
func createRequest(function Function, data []byte) (interface{}, error) {
|
|
request := function.Request()
|
|
if len(data) > 0 {
|
|
err := json.Unmarshal(data, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func createResponse(function Function, data []byte) (*gosoap.SOAPEnvelope, error) {
|
|
response := function.Response()
|
|
responseEnvelope := gosoap.NewSOAPEnvelope(response)
|
|
err := xml.Unmarshal(data, responseEnvelope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responseEnvelope, nil
|
|
}
|
|
|
|
// SendGetSnapshotRequest sends the Get request to retrieve the snapshot from the Onvif camera
|
|
// The parameter url is come from the "GetSnapshotURI" command.
|
|
func (dev *Device) SendGetSnapshotRequest(url string) (resp *http.Response, err error) {
|
|
soap := gosoap.NewEmptySOAP()
|
|
soap.AddRootNamespaces(Xlmns)
|
|
if dev.params.AuthMode == UsernameTokenAuth {
|
|
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
|
var req *http.Request
|
|
req, err = createHttpRequest(http.MethodGet, url, soap.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Basic auth might work for some camera
|
|
req.SetBasicAuth(dev.params.Username, dev.params.Password)
|
|
resp, err = dev.params.HttpClient.Do(req)
|
|
|
|
} else if dev.params.AuthMode == DigestAuth || dev.params.AuthMode == Both {
|
|
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
|
|
resp, err = dev.digestClient.Do(http.MethodGet, url, soap.String())
|
|
|
|
} else {
|
|
var req *http.Request
|
|
req, err = createHttpRequest(http.MethodGet, url, soap.String())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err = dev.params.HttpClient.Do(req)
|
|
}
|
|
return resp, err
|
|
}
|