From a646d69c671cedeba60da169dbf90e06cae75c65 Mon Sep 17 00:00:00 2001 From: Jozef Kralik Date: Fri, 19 Apr 2024 13:36:24 +0200 Subject: [PATCH] bridge: implement thing description to support Web of Things (#463) * bridge: implement thing description to support Web of Things --------- Co-authored-by: Daniel Adam --- bridge/device/cloud/manager_test.go | 4 +- bridge/device/device.go | 42 ++- bridge/device/options.go | 15 +- bridge/device/options_internal_test.go | 88 ++++++ bridge/device/thingDescription/manager.go | 231 ++++++++++++++++ bridge/getDevices_test.go | 6 +- bridge/getResource_test.go | 4 +- bridge/observeResource_test.go | 2 +- bridge/onboardDevice_test.go | 4 +- bridge/resources/device/resource.go | 2 +- bridge/resources/maintenance/resource.go | 5 +- bridge/resources/resource.go | 84 ++++-- bridge/resources/resource_test.go | 60 ++++ .../thingDescription/ocfResources.go | 34 +++ .../thingDescription/ocfResources.jsonld | 82 ++++++ bridge/resources/thingDescription/resource.go | 116 ++++++++ .../thingDescription/resource_test.go | 260 ++++++++++++++++++ bridge/service/service.go | 2 + bridge/test/test.go | 245 ++++++++++++++++- bridge/updateResource_test.go | 2 +- cmd/bridge-device/Dockerfile | 1 + cmd/bridge-device/bridge-device.jsonld | 39 +++ cmd/bridge-device/config.go | 16 +- cmd/bridge-device/config.yaml | 3 + cmd/bridge-device/main.go | 101 +++++-- go.mod | 2 + go.sum | 7 + schema/maintenance/maintenance.go | 9 + 28 files changed, 1396 insertions(+), 70 deletions(-) create mode 100644 bridge/device/options_internal_test.go create mode 100644 bridge/device/thingDescription/manager.go create mode 100644 bridge/resources/resource_test.go create mode 100644 bridge/resources/thingDescription/ocfResources.go create mode 100644 bridge/resources/thingDescription/ocfResources.jsonld create mode 100644 bridge/resources/thingDescription/resource.go create mode 100644 bridge/resources/thingDescription/resource_test.go create mode 100644 cmd/bridge-device/bridge-device.jsonld diff --git a/bridge/device/cloud/manager_test.go b/bridge/device/cloud/manager_test.go index c767f4c7..2b95e9a1 100644 --- a/bridge/device/cloud/manager_test.go +++ b/bridge/device/cloud/manager_test.go @@ -102,7 +102,7 @@ func TestManagerDeviceBecomesUnauthorized(t *testing.T) { }) deviceID := uuid.New().String() tickInterval := time.Second - d1 := bridgeTest.NewBridgedDevice(t, s1, deviceID, true, false, device.WithCloudOptions(cloud.WithTickInterval(tickInterval))) + d1 := bridgeTest.NewBridgedDevice(t, s1, deviceID, true, false, true, device.WithCloudOptions(cloud.WithTickInterval(tickInterval))) s1Shutdown := bridgeTest.RunBridgeService(s1) t.Cleanup(func() { _ = s1Shutdown() @@ -167,7 +167,7 @@ func TestProvisioningOnDeviceRestart(t *testing.T) { _ = s1.Shutdown() }) deviceID := uuid.New().String() - d1 := bridgeTest.NewBridgedDevice(t, s1, deviceID, true, false) + d1 := bridgeTest.NewBridgedDevice(t, s1, deviceID, true, false, true) s1Shutdown := bridgeTest.RunBridgeService(s1) t.Cleanup(func() { _ = s1Shutdown() diff --git a/bridge/device/device.go b/bridge/device/device.go index 83279218..e7c3569b 100644 --- a/bridge/device/device.go +++ b/bridge/device/device.go @@ -28,6 +28,7 @@ import ( "github.com/google/uuid" "github.com/plgd-dev/device/v2/bridge/device/cloud" "github.com/plgd-dev/device/v2/bridge/device/credential" + "github.com/plgd-dev/device/v2/bridge/device/thingDescription" "github.com/plgd-dev/device/v2/bridge/net" "github.com/plgd-dev/device/v2/bridge/resources" cloudResource "github.com/plgd-dev/device/v2/bridge/resources/cloud" @@ -35,6 +36,7 @@ import ( "github.com/plgd-dev/device/v2/bridge/resources/discovery" "github.com/plgd-dev/device/v2/bridge/resources/maintenance" credentialResource "github.com/plgd-dev/device/v2/bridge/resources/secure/credential" + thingDescriptionResource "github.com/plgd-dev/device/v2/bridge/resources/thingDescription" "github.com/plgd-dev/device/v2/pkg/eventloop" pkgLog "github.com/plgd-dev/device/v2/pkg/log" "github.com/plgd-dev/device/v2/schema" @@ -47,9 +49,10 @@ import ( "github.com/plgd-dev/go-coap/v3/message/codes" "github.com/plgd-dev/go-coap/v3/message/pool" "github.com/plgd-dev/go-coap/v3/pkg/sync" + wotTD "github.com/web-of-things-open-source/thingdescription-go/thingDescription" ) -type Resource interface { +type Resource = interface { Close() ETag() []byte GetHref() string @@ -59,18 +62,20 @@ type Resource interface { GetPolicyBitMask() schema.BitMask SetObserveHandler(loop *eventloop.Loop, createSubscription resources.CreateSubscriptionFunc) UpdateETag() + SupportsOperations() resources.SupportedOperation } type Device struct { - cfg Config - resources *sync.Map[string, Resource] - cloudManager *cloud.Manager - credentialManager *credential.Manager - onDeviceUpdated func(d *Device) - loop *eventloop.Loop - runLoop bool - done chan struct{} - stopped atomic.Bool + cfg Config + resources *sync.Map[string, Resource] + cloudManager *cloud.Manager + credentialManager *credential.Manager + thingDescriptionManager *thingDescription.Manager + onDeviceUpdated func(d *Device) + loop *eventloop.Loop + runLoop bool + done chan struct{} + stopped atomic.Bool } func NewLogger(id uuid.UUID, level pkgLog.Level) pkgLog.Logger { @@ -170,6 +175,15 @@ func New(cfg Config, opts ...Option) (*Device, error) { d.cloudManager = cm d.AddResources(cloudResource.New(cloudSchema.ResourceURI, d.cloudManager)) } + if o.getThingDescription != nil { + td := thingDescription.New(d, o.loop) + tdRes := thingDescriptionResource.New(thingDescriptionResource.ResourceURI, func(ctx context.Context, endpoints schema.Endpoints) *wotTD.ThingDescription { + return o.getThingDescription(ctx, d, endpoints) + }, td.RegisterSubscription) + tdRes.SetObserveHandler(o.loop, tdRes.CreateSubscription) + d.AddResources(tdRes) + d.thingDescriptionManager = td + } d.AddResources(resourcesDevice.New(plgdDevice.ResourceURI, d, o.getAdditionalProperties)) // oic/res is not discoverable @@ -212,6 +226,11 @@ func (d *Device) GetCloudManager() *cloud.Manager { return d.cloudManager } +// GetThingDescriptionManager returns thing description manager of the device. +func (d *Device) GetThingDescriptionManager() *thingDescription.Manager { + return d.thingDescriptionManager +} + func (d *Device) Range(f func(resourceHref string, resource Resource) bool) { d.resources.Range(f) } @@ -308,6 +327,9 @@ func (d *Device) Close() { if d.credentialManager != nil { d.credentialManager.Close() } + if d.thingDescriptionManager != nil { + d.thingDescriptionManager.Close() + } for _, resource := range d.resources.LoadAndDeleteAll() { resource.Close() } diff --git a/bridge/device/options.go b/bridge/device/options.go index ef930443..d8b0a008 100644 --- a/bridge/device/options.go +++ b/bridge/device/options.go @@ -19,15 +19,21 @@ package device import ( + "context" "crypto/x509" "github.com/plgd-dev/device/v2/bridge/device/cloud" "github.com/plgd-dev/device/v2/bridge/resources/device" "github.com/plgd-dev/device/v2/pkg/eventloop" "github.com/plgd-dev/device/v2/pkg/log" + "github.com/plgd-dev/device/v2/schema" + wotTD "github.com/web-of-things-open-source/thingdescription-go/thingDescription" ) -type OnDeviceUpdated func(d *Device) +type ( + OnDeviceUpdated func(d *Device) + GetThingDescription func(ctx context.Context, d *Device, endpoints schema.Endpoints) *wotTD.ThingDescription +) type CAPoolGetter interface { IsValid() bool @@ -43,6 +49,7 @@ type OptionsCfg struct { loop *eventloop.Loop runLoop bool cloudOptions []cloud.Option + getThingDescription GetThingDescription } type Option func(*OptionsCfg) @@ -89,3 +96,9 @@ func WithCloudOptions(cloudOptions ...cloud.Option) Option { o.cloudOptions = cloudOptions } } + +func WithThingDescription(getThingDescription GetThingDescription) Option { + return func(o *OptionsCfg) { + o.getThingDescription = getThingDescription + } +} diff --git a/bridge/device/options_internal_test.go b/bridge/device/options_internal_test.go new file mode 100644 index 00000000..683a0506 --- /dev/null +++ b/bridge/device/options_internal_test.go @@ -0,0 +1,88 @@ +/**************************************************************************** + * + * Copyright (c) 2024 plgd.dev s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"), + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the License. + * + ****************************************************************************/ + +package device + +import ( + "context" + "crypto/tls" + "crypto/x509" + "testing" + "time" + + "github.com/plgd-dev/device/v2/bridge/device/cloud" + "github.com/plgd-dev/device/v2/bridge/device/credential" + "github.com/plgd-dev/device/v2/pkg/eventloop" + "github.com/plgd-dev/device/v2/pkg/log" + "github.com/plgd-dev/device/v2/schema" + "github.com/stretchr/testify/require" + wotTD "github.com/web-of-things-open-source/thingdescription-go/thingDescription" +) + +func TestOptions(t *testing.T) { + cfg := OptionsCfg{} + + opts := []Option{} + onDeviceUpdated := func(*Device) { + // no-op + } + opts = append(opts, WithOnDeviceUpdated(onDeviceUpdated)) + + getAdditionalPropertiesForResponseFunc := func() map[string]interface{} { + return nil + } + opts = append(opts, WithGetAdditionalPropertiesForResponse(getAdditionalPropertiesForResponseFunc)) + + getCertificates := func(string) []tls.Certificate { + return nil + } + opts = append(opts, WithGetCertificates(getCertificates)) + + getCAPool := func() []*x509.Certificate { + return []*x509.Certificate{{}} + } + caPool := credential.MakeCAPool(nil, getCAPool) + opts = append(opts, WithCAPool(caPool)) + + logger := log.NewNilLogger() + opts = append(opts, WithLogger(logger)) + + loop := eventloop.New() + opts = append(opts, WithEventLoop(loop)) + + cloudOpt := cloud.WithTickInterval(time.Second) + opts = append(opts, WithCloudOptions(cloudOpt)) + + getThingDescription := func(context.Context, *Device, schema.Endpoints) *wotTD.ThingDescription { + return nil + } + opts = append(opts, WithThingDescription(getThingDescription)) + + for _, o := range opts { + o(&cfg) + } + + require.NotNil(t, cfg.onDeviceUpdated) + require.NotNil(t, cfg.getAdditionalProperties) + require.NotNil(t, cfg.getCertificates) + require.NotNil(t, cfg.caPool) + require.Equal(t, logger, cfg.logger) + require.Equal(t, loop, cfg.loop) + require.Len(t, cfg.cloudOptions, 1) + require.NotNil(t, cfg.getThingDescription) +} diff --git a/bridge/device/thingDescription/manager.go b/bridge/device/thingDescription/manager.go new file mode 100644 index 00000000..12a5973c --- /dev/null +++ b/bridge/device/thingDescription/manager.go @@ -0,0 +1,231 @@ +package thingDescription + +import ( + "net/url" + "reflect" + "sync/atomic" + + "github.com/fredbi/uri" + "github.com/google/uuid" + "github.com/plgd-dev/device/v2/bridge/net" + "github.com/plgd-dev/device/v2/bridge/resources" + "github.com/plgd-dev/device/v2/pkg/eventloop" + "github.com/plgd-dev/device/v2/schema" + "github.com/plgd-dev/go-coap/v3/message" + "github.com/plgd-dev/go-coap/v3/message/pool" + "github.com/plgd-dev/go-coap/v3/pkg/sync" + "github.com/web-of-things-open-source/thingdescription-go/thingDescription" +) + +var ( + SecurityNoSec = "nosec_sc" + SecurityDefinitions = map[string]thingDescription.SecurityScheme{ + SecurityNoSec: { + Scheme: "nosec", + }, + } + HTTPSWWWW3Org2022WotTdV11 = thingDescription.HTTPSWWWW3Org2022WotTdV11 + Context = thingDescription.ThingContext{ + Enum: &HTTPSWWWW3Org2022WotTdV11, + } +) + +// Resource to avoid import cycle also it is same as in Device package to avoid wrapping it +type Resource = interface { + Close() + ETag() []byte + GetHref() string + GetResourceTypes() []string + GetResourceInterfaces() []string + HandleRequest(req *net.Request) (*pool.Message, error) + GetPolicyBitMask() schema.BitMask + SetObserveHandler(loop *eventloop.Loop, createSubscription resources.CreateSubscriptionFunc) + UpdateETag() + SupportsOperations() resources.SupportedOperation +} + +type Device = interface { + GetID() uuid.UUID + GetName() string + Range(f func(resourceHref string, resource Resource) bool) +} + +type SubscriptionHandler = func(td *thingDescription.ThingDescription, closed bool) + +type Manager struct { + device Device + + subscriptions *sync.Map[uint64, SubscriptionHandler] + lastSubscription atomic.Uint64 + loop *eventloop.Loop + subChan chan struct{} + lastTD atomic.Pointer[thingDescription.ThingDescription] + stopped atomic.Bool +} + +func New(device Device, loop *eventloop.Loop) *Manager { + subChan := make(chan struct{}, 1) + t := Manager{ + device: device, + subscriptions: sync.NewMap[uint64, SubscriptionHandler](), + loop: loop, + subChan: subChan, + } + loop.Add(eventloop.NewReadHandler(reflect.ValueOf(subChan), t.subscriptionHandler)) + return &t +} + +func (t *Manager) Close() { + if !t.stopped.CompareAndSwap(false, true) { + return + } + t.loop.RemoveByChannels(reflect.ValueOf(t.subChan)) + close(t.subChan) + for _, sub := range t.subscriptions.LoadAndDeleteAll() { + sub(nil, true) + } +} + +func (t *Manager) subscriptionHandler(_ reflect.Value, closed bool) { + if closed { + return + } + + td := t.lastTD.Load() + if td == nil { + return + } + t.subscriptions.Range(func(_ uint64, value SubscriptionHandler) bool { + value(td, false) + return true + }) + t.lastTD.CompareAndSwap(td, nil) +} + +func (t *Manager) RegisterSubscription(subscription SubscriptionHandler) func() { + id := t.lastSubscription.Add(1) + t.subscriptions.Store(id, subscription) + return func() { + t.subscriptions.Delete(id) + } +} + +func (t *Manager) NotifySubscriptions(td thingDescription.ThingDescription) { + t.lastTD.Store(&td) + select { + case t.subChan <- struct{}{}: + default: + } +} + +func supportedOperationToTDOperation(ops resources.SupportedOperation) []string { + tdOps := make([]string, 0, 3) + if ops.HasOperation(resources.SupportedOperationRead) { + tdOps = append(tdOps, string(thingDescription.Readproperty)) + } + if ops.HasOperation(resources.SupportedOperationWrite) { + tdOps = append(tdOps, string(thingDescription.Writeproperty)) + } + if ops.HasOperation(resources.SupportedOperationObserve) { + tdOps = append(tdOps, string(thingDescription.Observeproperty), string(thingDescription.Unobserveproperty)) + } + if len(tdOps) == 0 { + return nil + } + return tdOps +} + +func boolToPtr(v bool) *bool { + if !v { + return nil + } + return &v +} + +func stringToPtr(v string) *string { + if v == "" { + return nil + } + return &v +} + +func createForms(deviceID uuid.UUID, href string, supportedOperations resources.SupportedOperation, setForm bool) []thingDescription.FormElementProperty { + if !setForm { + return nil + } + ops := supportedOperationToTDOperation(supportedOperations) + if len(ops) > 0 { + hrefStr := href + if deviceID != uuid.Nil { + hrefStr += "?di=" + deviceID.String() + } + href, err := url.Parse(hrefStr) + if err == nil { + return []thingDescription.FormElementProperty{ + { + ContentType: stringToPtr(message.AppCBOR.String()), + Op: &thingDescription.FormElementPropertyOp{ + StringArray: ops, + }, + Href: *href, + }, + } + } + } + return nil +} + +func PatchPropertyElement(prop thingDescription.PropertyElement, deviceID uuid.UUID, resource Resource, setForm bool) thingDescription.PropertyElement { + ops := resource.SupportsOperations() + observable := ops.HasOperation(resources.SupportedOperationObserve) + isReadOnly := ops.HasOperation(resources.SupportedOperationRead) && !ops.HasOperation(resources.SupportedOperationWrite) + isWriteOnly := ops.HasOperation(resources.SupportedOperationWrite) && !ops.HasOperation(resources.SupportedOperationRead) + resourceTypes := resource.GetResourceTypes() + + prop.Type = &thingDescription.TypeDeclaration{ + StringArray: resourceTypes, + } + prop.Observable = boolToPtr(observable) + prop.ReadOnly = boolToPtr(isReadOnly) + prop.WriteOnly = boolToPtr(isWriteOnly) + prop.Observable = boolToPtr(observable) + prop.Forms = createForms(deviceID, resource.GetHref(), ops, setForm) + return prop +} + +func PatchThingDescription(td thingDescription.ThingDescription, device Device, endpoint string, getPropertyElement func(resourceHref string, resource Resource) (thingDescription.PropertyElement, bool)) thingDescription.ThingDescription { + if td.Context == nil { + td.Context = &Context + } + id, err := uri.Parse("urn:uuid:" + device.GetID().String()) + if err == nil { + td.ID = id + } + td.Title = device.GetName() + if endpoint != "" { + // base + u, err := url.Parse(endpoint) + if err == nil { + td.Base = *u + } + // security + td.Security = &thingDescription.TypeDeclaration{ + String: &SecurityNoSec, + } + // securityDefinitions + td.SecurityDefinitions = SecurityDefinitions + } + + device.Range(func(resourceHref string, resource Resource) bool { + pe, ok := getPropertyElement(resourceHref, resource) + if !ok { + return true + } + if td.Properties == nil { + td.Properties = make(map[string]thingDescription.PropertyElement) + } + td.Properties[resourceHref] = pe + return true + }) + return td +} diff --git a/bridge/getDevices_test.go b/bridge/getDevices_test.go index 6412ca8f..ab0e0244 100644 --- a/bridge/getDevices_test.go +++ b/bridge/getDevices_test.go @@ -35,11 +35,11 @@ func TestGetDevices(t *testing.T) { _ = s.Shutdown() }) deviceID1 := uuid.New().String() - d1 := bridgeTest.NewBridgedDevice(t, s, deviceID1, false, false) + d1 := bridgeTest.NewBridgedDevice(t, s, deviceID1, false, false, false) deviceID2 := uuid.New().String() - d2 := bridgeTest.NewBridgedDevice(t, s, deviceID2, false, false) + d2 := bridgeTest.NewBridgedDevice(t, s, deviceID2, false, false, false) deviceID3 := uuid.New().String() - d3 := bridgeTest.NewBridgedDevice(t, s, deviceID3, false, false) + d3 := bridgeTest.NewBridgedDevice(t, s, deviceID3, false, false, false) defer func() { s.DeleteAndCloseDevice(d3.GetID()) s.DeleteAndCloseDevice(d2.GetID()) diff --git a/bridge/getResource_test.go b/bridge/getResource_test.go index f60c70ae..0c2f1196 100644 --- a/bridge/getResource_test.go +++ b/bridge/getResource_test.go @@ -42,9 +42,9 @@ func TestGetResource(t *testing.T) { _ = s.Shutdown() }) deviceID1 := uuid.New().String() - d1 := bridgeTest.NewBridgedDevice(t, s, deviceID1, false, false) + d1 := bridgeTest.NewBridgedDevice(t, s, deviceID1, false, false, false) deviceID2 := uuid.New().String() - d2 := bridgeTest.NewBridgedDevice(t, s, deviceID2, false, false) + d2 := bridgeTest.NewBridgedDevice(t, s, deviceID2, false, false, false) defer func() { s.DeleteAndCloseDevice(d2.GetID()) s.DeleteAndCloseDevice(d1.GetID()) diff --git a/bridge/observeResource_test.go b/bridge/observeResource_test.go index 1854a9a3..3bbcb77d 100644 --- a/bridge/observeResource_test.go +++ b/bridge/observeResource_test.go @@ -27,7 +27,7 @@ func TestObserveResource(t *testing.T) { t.Cleanup(func() { _ = s.Shutdown() }) - d := bridgeTest.NewBridgedDevice(t, s, uuid.New().String(), false, false) + d := bridgeTest.NewBridgedDevice(t, s, uuid.New().String(), false, false, false) defer func() { s.DeleteAndCloseDevice(d.GetID()) }() diff --git a/bridge/onboardDevice_test.go b/bridge/onboardDevice_test.go index 59a46a71..dde3fd29 100644 --- a/bridge/onboardDevice_test.go +++ b/bridge/onboardDevice_test.go @@ -40,12 +40,12 @@ func TestOnboardDevice(t *testing.T) { _ = s.Shutdown() }) deviceID := uuid.New().String() - d := bridgeTest.NewBridgedDevice(t, s, deviceID, true, false) + d := bridgeTest.NewBridgedDevice(t, s, deviceID, true, false, true) defer func() { s.DeleteAndCloseDevice(d.GetID()) }() deviceIDwithoutCAPool := uuid.New().String() - deviceWithoutCAPool := bridgeTest.NewBridgedDevice(t, s, deviceIDwithoutCAPool, true, true, device.WithCAPool(cloud.MakeCAPool(func() []*x509.Certificate { + deviceWithoutCAPool := bridgeTest.NewBridgedDevice(t, s, deviceIDwithoutCAPool, true, true, true, device.WithCAPool(cloud.MakeCAPool(func() []*x509.Certificate { return nil }, false))) defer func() { diff --git a/bridge/resources/device/resource.go b/bridge/resources/device/resource.go index c0fa455d..00cbbe7c 100644 --- a/bridge/resources/device/resource.go +++ b/bridge/resources/device/resource.go @@ -69,7 +69,7 @@ func (d *Resource) Get(request *net.Request) (*pool.Message, error) { // SpecificationVersion: "ocf.2.0.5", } if request.Interface() == interfaces.OC_IF_BASELINE { - deviceProperties.ResourceTypes = d.Resource.ResourceTypes + deviceProperties.ResourceTypes = d.Resource.GetResourceTypes() deviceProperties.Interfaces = d.Resource.ResourceInterfaces } properties := resources.MergeCBORStructs(additionalProperties, deviceProperties) diff --git a/bridge/resources/maintenance/resource.go b/bridge/resources/maintenance/resource.go index c4ded3e2..6489a73b 100644 --- a/bridge/resources/maintenance/resource.go +++ b/bridge/resources/maintenance/resource.go @@ -36,8 +36,9 @@ type Resource struct { } func (r *Resource) Get(request *net.Request) (*pool.Message, error) { - return resources.CreateResponseContent(request.Context(), maintenance.Maintenance{ - FactoryReset: false, + factoryReset := false + return resources.CreateResponseContent(request.Context(), maintenance.MaintenanceV1{ + FactoryReset: &factoryReset, }, codes.Content) } diff --git a/bridge/resources/resource.go b/bridge/resources/resource.go index 8d90d65f..ca7c86ba 100644 --- a/bridge/resources/resource.go +++ b/bridge/resources/resource.go @@ -29,8 +29,9 @@ import ( "reflect" "github.com/plgd-dev/device/v2/bridge/net" - "github.com/plgd-dev/device/v2/pkg/codec/cbor" + "github.com/plgd-dev/device/v2/pkg/codec/ocf" "github.com/plgd-dev/device/v2/pkg/eventloop" + "github.com/plgd-dev/device/v2/pkg/net/coap" "github.com/plgd-dev/device/v2/schema" "github.com/plgd-dev/go-coap/v3/message" "github.com/plgd-dev/go-coap/v3/message/codes" @@ -54,7 +55,6 @@ type subscription struct { type Resource struct { Href string - ResourceTypes []string ResourceInterfaces []string PolicyBitMask schema.BitMask getHandler GetHandlerFunc @@ -64,6 +64,7 @@ type Resource struct { createdSubscription *sync.Map[string, *subscription] etag atomic.Uint64 loop *eventloop.Loop + resourceTypes atomic.Pointer[[]string] } func (r *Resource) GetPolicyBitMask() schema.BitMask { @@ -74,8 +75,43 @@ func (r *Resource) GetHref() string { return r.Href } +type SupportedOperation int + +const ( + SupportedOperationRead SupportedOperation = 0x1 << iota + SupportedOperationWrite + SupportedOperationObserve +) + +func (o SupportedOperation) HasOperation(operation SupportedOperation) bool { + return o&operation != 0 +} + +func (r *Resource) SupportsOperations() SupportedOperation { + var operations SupportedOperation + if r.getHandler != nil { + operations |= SupportedOperationRead + } + if r.postHandler != nil { + operations |= SupportedOperationWrite + } + if r.PolicyBitMask&schema.Observable != 0 { + operations |= SupportedOperationObserve + } + return operations +} + +func (r *Resource) SetResourceTypes(resourceTypes []string) { + resourceTypes = Unique(resourceTypes) + r.resourceTypes.Store(&resourceTypes) +} + func (r *Resource) GetResourceTypes() []string { - return r.ResourceTypes + resourceTypes := r.resourceTypes.Load() + if resourceTypes == nil { + return nil + } + return *resourceTypes } func (r *Resource) GetResourceInterfaces() []string { @@ -97,51 +133,53 @@ func Unique(strSlice []string) []string { func NewResource(href string, getHandler GetHandlerFunc, postHandler PostHandlerFunc, resourceTypes, resourceInterfaces []string) *Resource { r := &Resource{ Href: href, - ResourceTypes: Unique(resourceTypes), ResourceInterfaces: Unique(resourceInterfaces), PolicyBitMask: schema.Discoverable | PublishToCloud, getHandler: getHandler, postHandler: postHandler, createdSubscription: sync.NewMap[string, *subscription](), } + r.SetResourceTypes(resourceTypes) r.etag.Store(GetETag()) return r } -func CreateResponseMethodNotAllowed(ctx context.Context, token message.Token) *pool.Message { +func createTextPlainResponse(ctx context.Context, token message.Token, code codes.Code, body []byte) *pool.Message { msg := pool.NewMessage(ctx) - msg.SetCode(codes.MethodNotAllowed) - msg.SetToken(token) + msg.SetCode(code) + if token != nil { + msg.SetToken(token) + } msg.SetContentFormat(message.TextPlain) - msg.SetBody(bytes.NewReader([]byte(fmt.Sprintf("unsupported method %v", codes.MethodNotAllowed)))) + msg.SetBody(bytes.NewReader(body)) return msg } -func CreateResponseContent(ctx context.Context, data interface{}, code codes.Code) (*pool.Message, error) { - if str, ok := data.(string); ok { - res := pool.NewMessage(ctx) - res.SetCode(code) - res.SetContentFormat(message.TextPlain) - res.SetBody(bytes.NewReader([]byte(str))) - return res, nil - } - d, err := cbor.Encode(data) +func CreateResponseMethodNotAllowed(ctx context.Context, token message.Token) *pool.Message { + return createTextPlainResponse(ctx, token, codes.MethodNotAllowed, []byte(fmt.Sprintf("unsupported method %v", codes.MethodNotAllowed))) +} + +func CreateResponseContentWithCodec(ctx context.Context, codec coap.Codec, data interface{}, code codes.Code) (*pool.Message, error) { + d, err := codec.Encode(data) if err != nil { return nil, err } res := pool.NewMessage(ctx) res.SetCode(code) - res.SetContentFormat(message.AppOcfCbor) + res.SetContentFormat(codec.ContentFormat()) res.SetBody(bytes.NewReader(d)) return res, nil } +func CreateResponseContent(ctx context.Context, data interface{}, code codes.Code) (*pool.Message, error) { + if str, ok := data.(string); ok { + return createTextPlainResponse(ctx, nil, code, []byte(str)), nil + } + return CreateResponseContentWithCodec(ctx, ocf.VNDOCFCBORCodec{}, data, code) +} + func CreateErrorResponse(ctx context.Context, code codes.Code, err error) (*pool.Message, error) { - res := pool.NewMessage(ctx) - res.SetCode(code) - res.SetContentFormat(message.TextPlain) - res.SetBody(bytes.NewReader([]byte(err.Error()))) - return res, nil + return createTextPlainResponse(ctx, nil, code, []byte(err.Error())), nil } func CreateResponseBadRequest(ctx context.Context, err error) (*pool.Message, error) { diff --git a/bridge/resources/resource_test.go b/bridge/resources/resource_test.go new file mode 100644 index 00000000..9820235f --- /dev/null +++ b/bridge/resources/resource_test.go @@ -0,0 +1,60 @@ +package resources_test + +import ( + "testing" + + "github.com/plgd-dev/device/v2/bridge/net" + "github.com/plgd-dev/device/v2/bridge/resources" + "github.com/plgd-dev/device/v2/schema" + "github.com/plgd-dev/go-coap/v3/message/pool" + "github.com/stretchr/testify/assert" +) + +func TestSupportedOperationHasOperation(t *testing.T) { + tests := []struct { + name string + operation resources.SupportedOperation + check resources.SupportedOperation + wantResult bool + }{ + {"Test Read", resources.SupportedOperationRead, resources.SupportedOperationRead, true}, + {"Test Write", resources.SupportedOperationWrite, resources.SupportedOperationWrite, true}, + {"Test Observe", resources.SupportedOperationObserve, resources.SupportedOperationObserve, true}, + {"Test Read and Write", resources.SupportedOperationRead | resources.SupportedOperationWrite, resources.SupportedOperationRead, true}, + {"Test Write and Observe", resources.SupportedOperationWrite | resources.SupportedOperationObserve, resources.SupportedOperationObserve, true}, + {"Test All", resources.SupportedOperationRead | resources.SupportedOperationWrite | resources.SupportedOperationObserve, resources.SupportedOperationRead, true}, + {"Test None", 0, resources.SupportedOperationRead, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.operation.HasOperation(tt.check) + assert.Equal(t, tt.wantResult, result) + }) + } +} + +func TestResourceSupportsOperations(t *testing.T) { + tests := []struct { + name string + getHandler resources.GetHandlerFunc + postHandler resources.PostHandlerFunc + policyBitMask schema.BitMask + wantOperations resources.SupportedOperation + }{ + {"Only Read", func(*net.Request) (*pool.Message, error) { return &pool.Message{}, nil }, nil, 0, resources.SupportedOperationRead}, + {"Only Write", nil, func(*net.Request) (*pool.Message, error) { return &pool.Message{}, nil }, 0, resources.SupportedOperationWrite}, + {"Read and Observe", func(*net.Request) (*pool.Message, error) { return &pool.Message{}, nil }, nil, schema.Observable, resources.SupportedOperationRead | resources.SupportedOperationObserve}, + {"All Operations", func(*net.Request) (*pool.Message, error) { return &pool.Message{}, nil }, func(*net.Request) (*pool.Message, error) { return &pool.Message{}, nil }, schema.Observable, resources.SupportedOperationRead | resources.SupportedOperationWrite | resources.SupportedOperationObserve}, + {"No Operations", nil, nil, 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := resources.NewResource("/test", tt.getHandler, tt.postHandler, nil, nil) + r.PolicyBitMask = tt.policyBitMask + operations := r.SupportsOperations() + assert.Equal(t, tt.wantOperations, operations) + }) + } +} diff --git a/bridge/resources/thingDescription/ocfResources.go b/bridge/resources/thingDescription/ocfResources.go new file mode 100644 index 00000000..63008024 --- /dev/null +++ b/bridge/resources/thingDescription/ocfResources.go @@ -0,0 +1,34 @@ +package thingDescription + +import ( + _ "embed" + + "github.com/web-of-things-open-source/thingdescription-go/thingDescription" +) + +//go:embed ocfResources.jsonld +var ocfResourcesData []byte +var ocfThingDescription thingDescription.ThingDescription + +func init() { + err := ocfThingDescription.UnmarshalJSON(ocfResourcesData) + if err != nil { + panic(err) + } + ocfResourcesData = nil +} + +func GetOCFThingDescription() thingDescription.ThingDescription { + return ocfThingDescription +} + +func GetOCFResourcePropertyElement(resourceHref string) (thingDescription.PropertyElement, bool) { + if ocfThingDescription.Properties == nil { + return thingDescription.PropertyElement{}, false + } + prop, ok := ocfThingDescription.Properties[resourceHref] + if !ok { + return thingDescription.PropertyElement{}, false + } + return prop, true +} diff --git a/bridge/resources/thingDescription/ocfResources.jsonld b/bridge/resources/thingDescription/ocfResources.jsonld new file mode 100644 index 00000000..673dba8e --- /dev/null +++ b/bridge/resources/thingDescription/ocfResources.jsonld @@ -0,0 +1,82 @@ +{ + "@context": "https://www.w3.org/2019/wot/td/v1", + "@type": [ + "Thing" + ], + "id": "urn:OCFResources", + "properties": { + "/oic/d": { + "title": "Device Information", + "type": "object", + "properties": { + "piid": { + "title": "Protocol Interface ID", + "type": "string", + "readOnly": true, + "format": "uuid" + }, + "n": { + "title": "Device Name", + "type": "string", + "readOnly": true + }, + "di": { + "title": "Device ID", + "type": "string", + "readOnly": true, + "format": "uuid" + } + } + }, + "/oic/mnt": { + "title": "Maintenance", + "type": "object", + "properties": { + "fr": { + "title": "Factory Reset", + "type": "boolean" + } + } + }, + "/CoapCloudConfResURI": { + "title": "CoapCloudConfResURI", + "type": "object", + "properties": { + "apn": { + "title": "Authorization provider name", + "type": "string" + }, + "cis": { + "title": "Cloud interface server", + "type": "string", + "format": "uri" + }, + "sid": { + "title": "Cloud ID", + "type": "string", + "format": "uuid" + }, + "at": { + "title": "Access token", + "type": "string", + "writeOnly": true + }, + "cps": { + "title": "Provisioning status", + "type": "string", + "enum": [ + "uninitialized", + "readytoregister", + "registering", + "registered", + "failed" + ] + }, + "clec": { + "title": "Last error code", + "type": "integer" + } + } + } + } +} \ No newline at end of file diff --git a/bridge/resources/thingDescription/resource.go b/bridge/resources/thingDescription/resource.go new file mode 100644 index 00000000..b7563329 --- /dev/null +++ b/bridge/resources/thingDescription/resource.go @@ -0,0 +1,116 @@ +/**************************************************************************** + * + * Copyright (c) 2024 plgd.dev s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"), + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the License. + * + ****************************************************************************/ + +package thingDescription + +import ( + "bytes" + "context" + "encoding/json" + "errors" + + "github.com/plgd-dev/device/v2/bridge/net" + "github.com/plgd-dev/device/v2/bridge/resources" + "github.com/plgd-dev/device/v2/schema" + "github.com/plgd-dev/device/v2/schema/interfaces" + "github.com/plgd-dev/go-coap/v3/message" + "github.com/plgd-dev/go-coap/v3/message/codes" + "github.com/plgd-dev/go-coap/v3/message/pool" + "github.com/web-of-things-open-source/thingdescription-go/thingDescription" +) + +const ( + ResourceURI = "/.well-known/wot" + ResourceType = "wot.thing" +) + +var MessageTypeTDJson = message.MediaType(432) ///< application/td+json + +type ( + GetThingDescription func(ctx context.Context, endpoints schema.Endpoints) *thingDescription.ThingDescription + RegisterSubscription func(subscription func(td *thingDescription.ThingDescription, closed bool)) func() +) + +type Resource struct { + *resources.Resource + onGetThingDescription GetThingDescription + onRegisterSubscription RegisterSubscription +} + +func (r *Resource) createMessage(request *net.Request, thingDescription *thingDescription.ThingDescription) (*pool.Message, error) { + dataJson, err := json.Marshal(thingDescription) + if err != nil { + return resources.CreateErrorResponse(request.Context(), codes.InternalServerError, err) + } + mediaType, err := request.Accept() + if err != nil { + mediaType = message.AppJSON + } + switch mediaType { + case message.AppJSON, MessageTypeTDJson: + res := pool.NewMessage(request.Context()) + res.SetCode(codes.Content) + res.SetContentFormat(mediaType) + res.SetBody(bytes.NewReader(dataJson)) + return res, nil + case message.AppCBOR, message.AppOcfCbor: + var v interface{} + // non-JSON marshalling the thingdescription-go library doesn't work correctly right now, so we need to marshal it to JSON first + // and then unmarshal it to an unnannotated map[string]interface{} which then marshals correctly to CBOR + err := json.Unmarshal(dataJson, &v) + if err != nil { + return resources.CreateErrorResponse(request.Context(), codes.InternalServerError, err) + } + return resources.CreateResponseContent(request.Context(), v, codes.Content) + } + return resources.CreateErrorResponse(request.Context(), codes.NotAcceptable, errors.New("unsupported accept content format")) +} + +func (r *Resource) Get(request *net.Request) (*pool.Message, error) { + thingDescription := r.onGetThingDescription(request.Context(), request.Endpoints) + if thingDescription == nil { + return resources.CreateErrorResponse(request.Context(), codes.NotFound, errors.New("thing description not found")) + } + return r.createMessage(request, thingDescription) +} + +func (r *Resource) CreateSubscription(req *net.Request, handler func(*pool.Message, error)) (func(), error) { + unregister := r.onRegisterSubscription(func(td *thingDescription.ThingDescription, closed bool) { + if closed { + handler(nil, errors.New("subscription closed")) + return + } + msg, err := r.createMessage(req, td) + handler(msg, err) + }) + return unregister, nil +} + +func New(uri string, onGetThingDescription GetThingDescription, onRegisterSubscription RegisterSubscription) *Resource { + r := &Resource{ + onGetThingDescription: onGetThingDescription, + onRegisterSubscription: onRegisterSubscription, + } + r.Resource = resources.NewResource(uri, + r.Get, + nil, + []string{ResourceType}, + []string{interfaces.OC_IF_BASELINE, interfaces.OC_IF_R}, + ) + return r +} diff --git a/bridge/resources/thingDescription/resource_test.go b/bridge/resources/thingDescription/resource_test.go new file mode 100644 index 00000000..9764247c --- /dev/null +++ b/bridge/resources/thingDescription/resource_test.go @@ -0,0 +1,260 @@ +/**************************************************************************** + * + * Copyright (c) 2024 plgd.dev s.r.o. + * + * Licensed under the Apache License, Version 2.0 (the "License"), + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the License. + * + ****************************************************************************/ + +package thingDescription_test + +import ( + "context" + "fmt" + "net/url" + "testing" + "time" + + "github.com/fredbi/uri" + "github.com/google/uuid" + "github.com/plgd-dev/device/v2/bridge/device/thingDescription" + thingDescriptionResource "github.com/plgd-dev/device/v2/bridge/resources/thingDescription" + "github.com/plgd-dev/device/v2/bridge/service" + bridgeTest "github.com/plgd-dev/device/v2/bridge/test" + "github.com/plgd-dev/device/v2/client" + "github.com/plgd-dev/device/v2/pkg/codec/json" + "github.com/plgd-dev/device/v2/pkg/codec/ocf" + "github.com/plgd-dev/device/v2/pkg/net/coap" + testClient "github.com/plgd-dev/device/v2/test/client" + "github.com/plgd-dev/go-coap/v3/message" + "github.com/plgd-dev/go-coap/v3/message/pool" + "github.com/stretchr/testify/require" + wotTD "github.com/web-of-things-open-source/thingdescription-go/thingDescription" +) + +type JSONCodec struct{} + +func (JSONCodec) ContentFormat() message.MediaType { return message.AppJSON } + +func (JSONCodec) Encode(v interface{}) ([]byte, error) { + return json.Encode(v) +} + +func errUnknownContentFormat(err error) error { + return fmt.Errorf("%w: %w", ocf.ErrUnknownContentFormat, err) +} + +func (JSONCodec) Decode(m *pool.Message, v interface{}) error { + if v == nil { + return nil + } + mt, err := m.Options().ContentFormat() + if err != nil { + return errUnknownContentFormat(err) + } + if mt != message.AppJSON { + return fmt.Errorf("not a JSON content format: %v", mt) + } + if m.Body() == nil { + return ocf.ErrEmptyBody + } + if err := json.ReadFrom(m.Body(), v); err != nil { + p, _ := m.Options().Path() + return fmt.Errorf("decoding failed for the message %v on %v", m.Token(), p) + } + return nil +} + +func getThingDescription(t *testing.T, data interface{}) wotTD.ThingDescription { + tdMap, ok := data.(map[interface{}]interface{}) + require.True(t, ok) + jsonData, err := json.Encode(tdMap) + require.NoError(t, err) + td := wotTD.ThingDescription{} + err = json.Decode(jsonData, &td) + require.NoError(t, err) + return td +} + +func getEndpoint(t *testing.T, c *client.Client, deviceID string) string { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + devices, err := c.GetDevicesDetails(ctx) + require.NoError(t, err) + require.NotEmpty(t, devices[deviceID]) + eps := devices[deviceID].Endpoints + require.NotEmpty(t, eps) + return eps[0].URI +} + +func getPatchedTD(td wotTD.ThingDescription, d service.Device, epURI string) wotTD.ThingDescription { + return thingDescription.PatchThingDescription(td, d, epURI, func(resourceHref string, resource thingDescription.Resource) (wotTD.PropertyElement, bool) { + return bridgeTest.GetPropertyElement(td, d, epURI, resourceHref, resource) + }) +} + +func TestGetThingDescription(t *testing.T) { + s := bridgeTest.NewBridgeService(t) + t.Cleanup(func() { + _ = s.Shutdown() + }) + deviceID := uuid.New().String() + d := bridgeTest.NewBridgedDevice(t, s, deviceID, true, true, true) + defer func() { + s.DeleteAndCloseDevice(d.GetID()) + }() + + cleanup := bridgeTest.RunBridgeService(s) + defer func() { + errC := cleanup() + require.NoError(t, errC) + }() + + c, err := testClient.NewTestSecureClientWithBridgeSupport() + require.NoError(t, err) + defer func() { + errC := c.Close(context.Background()) + require.NoError(t, errC) + }() + + td, err := bridgeTest.ThingDescription(true, true) + require.NoError(t, err) + epURI := getEndpoint(t, c, d.GetID().String()) + td = getPatchedTD(td, d, epURI) + + type args struct { + deviceID string + href string + opts []client.GetOption + } + tests := []struct { + name string + args args + want wotTD.ThingDescription + wantErr bool + }{ + { + name: "cbor", + args: args{ + deviceID: d.GetID().String(), + href: thingDescriptionResource.ResourceURI, + }, + want: td, + }, + { + name: "json", + args: args{ + deviceID: d.GetID().String(), + href: thingDescriptionResource.ResourceURI, + opts: []client.GetOption{ + client.WithCodec(JSONCodec{}), + }, + }, + want: td, + }, + { + name: "json", + args: args{ + deviceID: d.GetID().String(), + href: thingDescriptionResource.ResourceURI, + opts: []client.GetOption{ + client.WithCodec(ocf.RawCodec{ + EncodeMediaType: message.TextPlain, + DecodeMediaTypes: []message.MediaType{message.TextPlain}, + }), + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runCtx, runCancel := context.WithTimeout(context.Background(), time.Second*8) + defer runCancel() + got := coap.DetailedResponse[interface{}]{} + err := c.GetResource(runCtx, tt.args.deviceID, tt.args.href, &got, tt.args.opts...) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + got.ETag = nil + require.Equal(t, tt.want, getThingDescription(t, got.Body)) + }) + } +} + +func TestObserveThingDescription(t *testing.T) { + s := bridgeTest.NewBridgeService(t) + t.Cleanup(func() { + _ = s.Shutdown() + }) + + deviceID := uuid.New().String() + td, err := bridgeTest.ThingDescription(true, true) + require.NoError(t, err) + d := bridgeTest.NewBridgedDeviceWithThingDescription(t, s, deviceID, true, true, &td) + defer func() { + s.DeleteAndCloseDevice(d.GetID()) + }() + + cleanup := bridgeTest.RunBridgeService(s) + defer func() { + errC := cleanup() + require.NoError(t, errC) + }() + + c, err := testClient.NewTestSecureClientWithBridgeSupport() + require.NoError(t, err) + defer func() { + errC := c.Close(context.Background()) + require.NoError(t, errC) + }() + + epURI := getEndpoint(t, c, d.GetID().String()) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*8) + defer cancel() + + h := testClient.MakeMockResourceObservationHandler() + obsID, err := c.ObserveResource(ctx, d.GetID().String(), thingDescriptionResource.ResourceURI, h) + require.NoError(t, err) + defer func() { + _, errC := c.StopObservingResource(ctx, obsID) + require.NoError(t, errC) + }() + + n, err := h.WaitForNotification(ctx) + require.NoError(t, err) + var originalResource map[interface{}]interface{} + err = n(&originalResource) + require.NoError(t, err) + require.Equal(t, getPatchedTD(td, d, epURI), getThingDescription(t, originalResource)) + + base, err := url.Parse("http://localhost:8080") + require.NoError(t, err) + id, err := uri.Parse("urn:uuid:" + deviceID) + require.NoError(t, err) + td2 := wotTD.ThingDescription{ + Base: *base, + ID: id, + } + d.GetThingDescriptionManager().NotifySubscriptions(td2) + n, err = h.WaitForNotification(ctx) + require.NoError(t, err) + var changedResource map[interface{}]interface{} + err = n(&changedResource) + require.NoError(t, err) + require.Equal(t, td2, getThingDescription(t, changedResource)) +} diff --git a/bridge/service/service.go b/bridge/service/service.go index a141ec87..6bd2866a 100644 --- a/bridge/service/service.go +++ b/bridge/service/service.go @@ -26,6 +26,7 @@ import ( "github.com/google/uuid" "github.com/plgd-dev/device/v2/bridge/device" "github.com/plgd-dev/device/v2/bridge/device/cloud" + "github.com/plgd-dev/device/v2/bridge/device/thingDescription" "github.com/plgd-dev/device/v2/bridge/net" "github.com/plgd-dev/device/v2/bridge/resources" "github.com/plgd-dev/device/v2/bridge/resources/discovery" @@ -62,6 +63,7 @@ type Device interface { GetCloudManager() *cloud.Manager GetLoop() *eventloop.Loop + GetThingDescriptionManager() *thingDescription.Manager } type Service struct { diff --git a/bridge/test/test.go b/bridge/test/test.go index cd0753a7..8b9ea736 100644 --- a/bridge/test/test.go +++ b/bridge/test/test.go @@ -19,17 +19,24 @@ package test import ( + "context" "crypto/tls" "crypto/x509" + "encoding/json" "testing" "github.com/google/uuid" "github.com/plgd-dev/device/v2/bridge/device" "github.com/plgd-dev/device/v2/bridge/device/cloud" + "github.com/plgd-dev/device/v2/bridge/device/thingDescription" "github.com/plgd-dev/device/v2/bridge/service" "github.com/plgd-dev/device/v2/pkg/log" + "github.com/plgd-dev/device/v2/schema" + schemaCloud "github.com/plgd-dev/device/v2/schema/cloud" + "github.com/plgd-dev/device/v2/schema/credential" "github.com/plgd-dev/device/v2/test" "github.com/stretchr/testify/require" + wotTD "github.com/web-of-things-open-source/thingdescription-go/thingDescription" ) const ( @@ -103,9 +110,245 @@ func makeDeviceConfig(id uuid.UUID, cloudEnabled bool, credentialEnabled bool) d return cfg } -func NewBridgedDevice(t *testing.T, s *service.Service, id string, cloudEnabled bool, credentialEnabled bool, opts ...device.Option) service.Device { +func GetPropertyElement(td wotTD.ThingDescription, device thingDescription.Device, endpoint string, resourceHref string, resource thingDescription.Resource) (wotTD.PropertyElement, bool) { + propElement, ok := td.Properties[resourceHref] + if !ok { + return wotTD.PropertyElement{}, false + } + propElement = thingDescription.PatchPropertyElement(propElement, device.GetID(), resource, endpoint != "") + return propElement, true +} + +func NewBridgedDevice(t *testing.T, s *service.Service, id string, cloudEnabled, credentialEnabled, thingDescriptionEnabled bool, opts ...device.Option) service.Device { u, err := uuid.Parse(id) require.NoError(t, err) cfg := makeDeviceConfig(u, cloudEnabled, credentialEnabled) + if thingDescriptionEnabled { + td, err := ThingDescription(cloudEnabled, credentialEnabled) + require.NoError(t, err) + return NewBridgedDeviceWithThingDescription(t, s, id, cloudEnabled, credentialEnabled, &td, opts...) + } return NewBridgedDeviceWithConfig(t, s, cfg, opts...) } + +func NewBridgedDeviceWithThingDescription(t *testing.T, s *service.Service, id string, cloudEnabled, credentialEnabled bool, td *wotTD.ThingDescription, opts ...device.Option) service.Device { + u, err := uuid.Parse(id) + require.NoError(t, err) + cfg := makeDeviceConfig(u, cloudEnabled, credentialEnabled) + if td != nil { + opts = append(opts, device.WithThingDescription(func(_ context.Context, device *device.Device, endpoints schema.Endpoints) *wotTD.ThingDescription { + endpoint := "" + if len(endpoints) > 0 { + endpoint = endpoints[0].URI + } + newTD := thingDescription.PatchThingDescription(*td, device, endpoint, + func(resourceHref string, resource thingDescription.Resource) (wotTD.PropertyElement, bool) { + return GetPropertyElement(*td, device, endpoint, resourceHref, resource) + }) + return &newTD + })) + } + return NewBridgedDeviceWithConfig(t, s, cfg, opts...) +} + +func ThingDescription(cloudEnabled, credentialEnabled bool) (wotTD.ThingDescription, error) { + type ThingDescription struct { + Context string `json:"@context"` + Type []string `json:"@type"` + ID string `json:"id"` + Properties map[string]interface{} `json:"properties"` + } + + td := ThingDescription{ + Context: "https://www.w3.org/2019/wot/td/v1", + Type: []string{"Thing"}, + ID: "urn:uuid:bridge", + Properties: map[string]interface{}{ + "/oic/d": map[string]interface{}{ + "title": "Device Information", + "type": "object", + "properties": map[string]interface{}{ + "piid": map[string]interface{}{ + "title": "Protocol Interface ID", + "type": "string", + "readOnly": true, + "format": "uuid", + }, + "n": map[string]interface{}{ + "title": "Device Name", + "type": "string", + "readOnly": true, + }, + "di": map[string]interface{}{ + "title": "Device ID", + "type": "string", + "readOnly": true, + "format": "uuid", + }, + }, + }, + "/oic/mnt": map[string]interface{}{ + "title": "Maintenance", + "type": "object", + "properties": map[string]interface{}{ + "fr": map[string]interface{}{ + "title": "Factory Reset", + "type": "boolean", + }, + }, + }, + }, + } + + if cloudEnabled { + td.Properties[schemaCloud.ResourceURI] = map[string]interface{}{ + "title": "CoapCloudConfResURI", + "type": "object", + "properties": map[string]interface{}{ + "apn": map[string]interface{}{ + "title": "Authorization provider name", + "type": "string", + }, + "cis": map[string]interface{}{ + "title": "Cloud interface server", + "type": "string", + "format": "uri", + }, + "sid": map[string]interface{}{ + "title": "Cloud ID", + "type": "string", + "format": "uuid", + }, + "at": map[string]interface{}{ + "title": "Access token", + "type": "string", + }, + "cps": map[string]interface{}{ + "title": "Provisioning status", + "type": "string", + "enum": []schemaCloud.ProvisioningStatus{ + schemaCloud.ProvisioningStatus_UNINITIALIZED, + schemaCloud.ProvisioningStatus_READY_TO_REGISTER, + schemaCloud.ProvisioningStatus_REGISTERING, + schemaCloud.ProvisioningStatus_REGISTERED, + schemaCloud.ProvisioningStatus_FAILED, + }, + }, + "clec": map[string]interface{}{ + "title": "Last error code", + "type": "integer", + }, + }, + } + } + + if credentialEnabled { + td.Properties[credential.ResourceURI] = map[string]interface{}{ + "title": "Credential", + "type": "object", + "properties": map[string]interface{}{ + "credid": map[string]interface{}{ + "title": "Credential ID", + "type": "integer", + }, + "credtype": map[string]interface{}{ + "title": "Credential Type", + "type": "integer", + "enum": []int{ + int(credential.CredentialType_EMPTY), + int(credential.CredentialType_SYMMETRIC_PAIR_WISE), + int(credential.CredentialType_SYMMETRIC_GROUP), + int(credential.CredentialType_ASYMMETRIC_SIGNING), + int(credential.CredentialType_ASYMMETRIC_SIGNING_WITH_CERTIFICATE), + int(credential.CredentialType_PIN_OR_PASSWORD), + int(credential.CredentialType_ASYMMETRIC_ENCRYPTION_KEY), + }, + }, + "subjectuuid": map[string]interface{}{ + "title": "Subject UUID", + "type": "string", + }, + "credusage": map[string]interface{}{ + "title": "Credential Usage", + "type": "string", + "enum": []credential.CredentialUsage{ + credential.CredentialUsage_TRUST_CA, + credential.CredentialUsage_CERT, + credential.CredentialUsage_ROLE_CERT, + credential.CredentialUsage_MFG_TRUST_CA, + credential.CredentialUsage_MFG_CERT, + }, + }, + "privatedata": map[string]interface{}{ + "title": "Private Data", + "type": "object", + "properties": map[string]interface{}{ + "data": map[string]interface{}{ + "title": "Data", + "type": "string", + }, + "encoding": map[string]interface{}{ + "title": "Encoding", + "type": "string", + "enum": []credential.CredentialPrivateDataEncoding{ + credential.CredentialPrivateDataEncoding_JWT, + credential.CredentialPrivateDataEncoding_CWT, + credential.CredentialPrivateDataEncoding_BASE64, + credential.CredentialPrivateDataEncoding_URI, + credential.CredentialPrivateDataEncoding_HANDLE, + credential.CredentialPrivateDataEncoding_RAW, + }, + }, + }, + }, + "publicdata": map[string]interface{}{ + "title": "Public Data", + "type": "object", + "properties": map[string]interface{}{ + "data": map[string]interface{}{ + "title": "Data", + "type": "string", + }, + "encoding": map[string]interface{}{ + "title": "Encoding", + "type": "string", + "enum": []credential.CredentialPublicDataEncoding{ + credential.CredentialPublicDataEncoding_JWT, + credential.CredentialPublicDataEncoding_CWT, + credential.CredentialPublicDataEncoding_BASE64, + credential.CredentialPublicDataEncoding_URI, + credential.CredentialPublicDataEncoding_PEM, + credential.CredentialPublicDataEncoding_DER, + credential.CredentialPublicDataEncoding_RAW, + }, + }, + }, + }, + "roleid": map[string]interface{}{ + "title": "Role ID", + "type": "object", + "properties": map[string]interface{}{ + "authority": map[string]interface{}{ + "title": "Authority", + "type": "string", + }, + "role": map[string]interface{}{ + "title": "Role", + "type": "string", + }, + }, + }, + "tag": map[string]interface{}{ + "title": "Tag", + "type": "string", + }, + }, + } + } + + tdJson, err := json.Marshal(td) + if err != nil { + return wotTD.ThingDescription{}, err + } + return wotTD.UnmarshalThingDescription(tdJson) +} diff --git a/bridge/updateResource_test.go b/bridge/updateResource_test.go index 72f8ff0e..5def2cb4 100644 --- a/bridge/updateResource_test.go +++ b/bridge/updateResource_test.go @@ -56,7 +56,7 @@ func TestUpdateResource(t *testing.T) { t.Cleanup(func() { _ = s.Shutdown() }) - d := bridgeTest.NewBridgedDevice(t, s, uuid.New().String(), false, false) + d := bridgeTest.NewBridgedDevice(t, s, uuid.New().String(), false, false, false) defer func() { s.DeleteAndCloseDevice(d.GetID()) }() diff --git a/cmd/bridge-device/Dockerfile b/cmd/bridge-device/Dockerfile index e910aeef..843d84b3 100644 --- a/cmd/bridge-device/Dockerfile +++ b/cmd/bridge-device/Dockerfile @@ -21,4 +21,5 @@ COPY --from=security-provider /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=build /go/bin/bridge-device /usr/local/bin/bridge-device USER nonroot COPY ./cmd/bridge-device/config.yaml /config.yaml +COPY ./cmd/bridge-device/bridge-device.jsonld /bridge-device.jsonld ENTRYPOINT [ "/usr/local/bin/bridge-device" ] diff --git a/cmd/bridge-device/bridge-device.jsonld b/cmd/bridge-device/bridge-device.jsonld new file mode 100644 index 00000000..245902d7 --- /dev/null +++ b/cmd/bridge-device/bridge-device.jsonld @@ -0,0 +1,39 @@ +{ + "@context": "https://www.w3.org/2019/wot/td/v1", + "@type": [ + "Thing" + ], + "id": "urn:uuid:bridge", + "properties": { + "/test/0": { + "title": "Test Resource", + "type": "object", + "properties": { + "Name": { + "title": "Name", + "type": "string" + } + } + }, + "/test/1": { + "title": "Test Resource", + "type": "object", + "properties": { + "Name": { + "title": "Name", + "type": "string" + } + } + }, + "/test/2": { + "title": "Test Resource", + "type": "object", + "properties": { + "Name": { + "title": "Name", + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/cmd/bridge-device/config.go b/cmd/bridge-device/config.go index e349e55f..c1162365 100644 --- a/cmd/bridge-device/config.go +++ b/cmd/bridge-device/config.go @@ -37,6 +37,11 @@ type CredentialConfig struct { Enabled bool `yaml:"enabled" json:"enabled" description:"enable credential manager"` } +type ThingDescriptionConfig struct { + Enabled bool `yaml:"enabled" json:"enabled" description:"enable thing description"` + File string `yaml:"file" json:"file" description:"file path to the thing description"` +} + func (c *CloudConfig) Validate() error { if c.Enabled { return c.TLS.Validate() @@ -46,11 +51,12 @@ func (c *CloudConfig) Validate() error { type Config struct { service.Config `yaml:",inline"` - Log LogConfig `yaml:"log" json:"log"` - Cloud CloudConfig `yaml:"cloud" json:"cloud"` - Credential CredentialConfig `yaml:"credential" json:"credential"` - NumGeneratedBridgedDevices int `yaml:"numGeneratedBridgedDevices"` - NumResourcesPerDevice int `yaml:"numResourcesPerDevice"` + Log LogConfig `yaml:"log" json:"log"` + Cloud CloudConfig `yaml:"cloud" json:"cloud"` + Credential CredentialConfig `yaml:"credential" json:"credential"` + ThingDescription ThingDescriptionConfig `yaml:"thingDescription" json:"thingDescription"` + NumGeneratedBridgedDevices int `yaml:"numGeneratedBridgedDevices"` + NumResourcesPerDevice int `yaml:"numResourcesPerDevice"` } func (c *Config) Validate() error { diff --git a/cmd/bridge-device/config.yaml b/cmd/bridge-device/config.yaml index e0b08dad..098c4e00 100644 --- a/cmd/bridge-device/config.yaml +++ b/cmd/bridge-device/config.yaml @@ -12,5 +12,8 @@ cloud: enabled: true credential: enabled: true +thingDescription: + enabled: true + file: "bridge-device.jsonld" numGeneratedBridgedDevices: 3 numResourcesPerDevice: 16 diff --git a/cmd/bridge-device/main.go b/cmd/bridge-device/main.go index ec3a1050..62ebb795 100644 --- a/cmd/bridge-device/main.go +++ b/cmd/bridge-device/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "context" "crypto/tls" "crypto/x509" "errors" @@ -18,21 +19,28 @@ import ( "github.com/google/uuid" "github.com/plgd-dev/device/v2/bridge/device" "github.com/plgd-dev/device/v2/bridge/device/cloud" + "github.com/plgd-dev/device/v2/bridge/device/thingDescription" "github.com/plgd-dev/device/v2/bridge/net" "github.com/plgd-dev/device/v2/bridge/resources" + thingDescriptionResource "github.com/plgd-dev/device/v2/bridge/resources/thingDescription" "github.com/plgd-dev/device/v2/bridge/service" "github.com/plgd-dev/device/v2/pkg/codec/cbor" codecOcf "github.com/plgd-dev/device/v2/pkg/codec/ocf" "github.com/plgd-dev/device/v2/pkg/log" pkgX509 "github.com/plgd-dev/device/v2/pkg/security/x509" + "github.com/plgd-dev/device/v2/schema" + deviceResource "github.com/plgd-dev/device/v2/schema/device" "github.com/plgd-dev/device/v2/schema/interfaces" "github.com/plgd-dev/go-coap/v3/message" "github.com/plgd-dev/go-coap/v3/message/codes" "github.com/plgd-dev/go-coap/v3/message/pool" coapSync "github.com/plgd-dev/go-coap/v3/pkg/sync" + wotTD "github.com/web-of-things-open-source/thingdescription-go/thingDescription" "gopkg.in/yaml.v3" ) +const myPropertyKey = "my-property" + func loadConfig(configFile string) (Config, error) { // Sanitize the configFile variable to ensure it only contains a valid file path configFile = filepath.Clean(configFile) @@ -194,36 +202,97 @@ func handleSignals(s *service.Service) { } } -func main() { - configFile := flag.String("config", "config.yaml", "path to config file") - flag.Parse() - cfg, err := loadConfig(*configFile) +func getCloudOpts(cfg Config) ([]device.Option, error) { + caPool, cert, err := getCloudTLS(cfg.Cloud, cfg.Credential.Enabled) if err != nil { - panic(err) + return nil, err } - s, err := service.New(cfg.Config, service.WithLogger(log.NewStdLogger(cfg.Log.Level))) + opts := []device.Option{device.WithCAPool(caPool)} + if cert != nil { + opts = append(opts, device.WithGetCertificates(func(string) []tls.Certificate { + return []tls.Certificate{*cert} + })) + } + return opts, nil +} + +func getTDOpts(cfg Config) ([]device.Option, error) { + tdJson, err := os.ReadFile(cfg.ThingDescription.File) if err != nil { - panic(err) + return nil, err + } + td, err := wotTD.UnmarshalThingDescription(tdJson) + if err != nil { + return nil, err } + return []device.Option{device.WithThingDescription(func(_ context.Context, dev *device.Device, endpoints schema.Endpoints) *wotTD.ThingDescription { + endpoint := "" + if len(endpoints) > 0 { + endpoint = endpoints[0].URI + } + newTD := thingDescription.PatchThingDescription(td, dev, endpoint, func(resourceHref string, resource thingDescription.Resource) (wotTD.PropertyElement, bool) { + propElement, ok := td.Properties[resourceHref] + if !ok { + propElement, ok = thingDescriptionResource.GetOCFResourcePropertyElement(resourceHref) + if ok && resourceHref == deviceResource.ResourceURI && propElement.Properties != nil && propElement.Properties.DataSchemaMap != nil { + stringType := wotTD.String + readOnly := true + propElement.Properties.DataSchemaMap[myPropertyKey] = wotTD.DataSchema{ + DataSchemaType: &stringType, + ReadOnly: &readOnly, + } + } + } + if !ok { + return wotTD.PropertyElement{}, false + } + propElement = thingDescription.PatchPropertyElement(propElement, dev.GetID(), resource, endpoint != "") + return propElement, true + }) + return &newTD + })}, nil +} +func getOpts(cfg Config) ([]device.Option, error) { opts := []device.Option{ device.WithGetAdditionalPropertiesForResponse(func() map[string]interface{} { return map[string]interface{}{ - "my-property": "my-value", + myPropertyKey: "my-value", } }), } if cfg.Cloud.Enabled { - caPool, cert, errC := getCloudTLS(cfg.Cloud, cfg.Credential.Enabled) - if errC != nil { - panic(errC) + cloudOpts, err := getCloudOpts(cfg) + if err != nil { + return nil, err } - opts = append(opts, device.WithCAPool(caPool)) - if cert != nil { - opts = append(opts, device.WithGetCertificates(func(string) []tls.Certificate { - return []tls.Certificate{*cert} - })) + opts = append(opts, cloudOpts...) + } + if cfg.ThingDescription.Enabled { + tdOpts, err := getTDOpts(cfg) + if err != nil { + return nil, err } + opts = append(opts, tdOpts...) + } + return opts, nil +} + +func main() { + configFile := flag.String("config", "config.yaml", "path to config file") + flag.Parse() + cfg, err := loadConfig(*configFile) + if err != nil { + panic(err) + } + s, err := service.New(cfg.Config, service.WithLogger(log.NewStdLogger(cfg.Log.Level))) + if err != nil { + panic(err) + } + + opts, err := getOpts(cfg) + if err != nil { + panic(err) } for i := 0; i < cfg.NumGeneratedBridgedDevices; i++ { diff --git a/go.mod b/go.mod index 0420bbf1..40fc48be 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/plgd-dev/device/v2 go 1.20 require ( + github.com/fredbi/uri v1.1.0 github.com/fxamacker/cbor/v2 v2.6.0 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/google/uuid v1.6.0 @@ -16,6 +17,7 @@ require ( github.com/plgd-dev/kit/v2 v2.0.0-20211006190727-057b33161b90 github.com/stretchr/testify v1.9.0 github.com/ugorji/go/codec v1.2.12 + github.com/web-of-things-open-source/thingdescription-go v0.0.0-20240417084639-a2aca7547975 go.uber.org/atomic v1.11.0 golang.org/x/sync v0.6.0 google.golang.org/grpc v1.63.0 diff --git a/go.sum b/go.sum index 3cfca72e..c49536db 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,9 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= +github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= +github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fxamacker/cbor/v2 v2.2.0/go.mod h1:TA1xS00nchWmaBnEIxPSE5oHLuJBAVvqrtAnWBwBCVo= github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA= @@ -49,6 +52,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -94,6 +98,7 @@ github.com/pion/transport/v3 v3.0.2 h1:r+40RJR25S9w3jbA6/5uEPTzcdn7ncyU44RWCbHkL github.com/pion/transport/v3 v3.0.2/go.mod h1:nIToODoOlb5If2jF9y2Igfx3PFYWfuXi37m0IlWa/D0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/plgd-dev/go-coap/v2 v2.0.4-0.20200819112225-8eb712b901bc/go.mod h1:+tCi9Q78H/orWRtpVWyBgrr4vKFo2zYtbbxUllerBp4= github.com/plgd-dev/go-coap/v2 v2.4.1-0.20210517130748-95c37ac8e1fa/go.mod h1:rA7fc7ar+B/qa+Q0hRqv7yj/EMtIlmo1l7vkQGSrHPU= github.com/plgd-dev/go-coap/v3 v3.3.4-0.20240404104253-8d54d1cdfc79 h1:Kaf/67M7+UVsRbNHQx3DDOLzpDI6RE/oYZz2v+a4csg= @@ -128,6 +133,8 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.12.0/go.mod h1:229t1eWu9UXTPmoUkbpN/fctKPBY4IJoFXQnxHGXy6E= github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +github.com/web-of-things-open-source/thingdescription-go v0.0.0-20240417084639-a2aca7547975 h1:OSKuqET6tBjpWdlN4/ZevP26nhHeUrhVb8jO8NfRKJA= +github.com/web-of-things-open-source/thingdescription-go v0.0.0-20240417084639-a2aca7547975/go.mod h1:OPqnw2lEwlmWVFbBZlpNreJqoqDKNuEr0b8zwYj8WyU= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/schema/maintenance/maintenance.go b/schema/maintenance/maintenance.go index a788f009..df646f2d 100644 --- a/schema/maintenance/maintenance.go +++ b/schema/maintenance/maintenance.go @@ -33,6 +33,15 @@ type Maintenance struct { LastHTTPError int `json:"err,omitempty"` } +type MaintenanceV1 struct { + ResourceTypes []string `json:"rt,omitempty"` + Interfaces []string `json:"if,omitempty"` + Name string `json:"n,omitempty"` + FactoryReset *bool `json:"fr,omitempty"` + Reboot *bool `json:"rb,omitempty"` + LastHTTPError *int `json:"err,omitempty"` +} + type MaintenanceUpdateRequest struct { FactoryReset bool `json:"fr,omitempty"` Reboot bool `json:"rb,omitempty"`