From c32d898a3082b2b5a95593621229863281b9fba8 Mon Sep 17 00:00:00 2001 From: Frank Mai Date: Mon, 13 Apr 2020 22:36:54 +0800 Subject: [PATCH] feat: add modbus adaptor add modbus adaptor logic Co-authored-by: shanewxy <592491808@qq.com> Signed-off-by: shanewxy <592491808@qq.com> --- adaptors/modbus/Dockerfile | 12 + adaptors/modbus/Dockerfile.dapper | 36 ++ adaptors/modbus/Makefile | 49 +++ adaptors/modbus/README-cn.md | 108 ++++++ adaptors/modbus/README.md | 134 ++++++++ .../modbus/api/v1alpha1/groupversion_info.go | 35 ++ .../modbus/api/v1alpha1/modbusdevice_types.go | 140 ++++++++ .../api/v1alpha1/zz_generated.deepcopy.go | 254 +++++++++++++++ adaptors/modbus/cmd/modbus/main.go | 35 ++ adaptors/modbus/deploy/e2e/all_in_one.yaml | 302 +++++++++++++++++ adaptors/modbus/deploy/e2e/dl_modbus_rtu.yaml | 39 +++ adaptors/modbus/deploy/e2e/dl_modbus_tcp.yaml | 43 +++ .../devices.edge.cattle.io_modbusdevices.yaml | 208 ++++++++++++ .../deploy/manifests/crd/kustomization.yaml | 6 + .../overlays/default/kustomization.yaml | 25 ++ .../deploy/manifests/rbac/kustomization.yaml | 6 + .../modbus/deploy/manifests/rbac/role.yaml | 28 ++ .../deploy/manifests/rbac/role_binding.yaml | 14 + .../deploy/manifests/workload/daemonset.yaml | 38 +++ .../manifests/workload/kustomization.yaml | 2 + adaptors/modbus/hack/lib/constant.sh | 7 + adaptors/modbus/hack/make-rules/adaptor.sh | 308 ++++++++++++++++++ adaptors/modbus/pkg/adaptor/service.go | 120 +++++++ adaptors/modbus/pkg/modbus/modbus.go | 41 +++ adaptors/modbus/pkg/physical/converter.go | 111 +++++++ .../modbus/pkg/physical/converter_test.go | 115 +++++++ adaptors/modbus/pkg/physical/device.go | 242 ++++++++++++++ adaptors/modbus/pkg/physical/handler.go | 9 + adaptors/modbus/pkg/physical/parameters.go | 28 ++ adaptors/modbus/test/deploy_rtu.md | 75 +++++ 30 files changed, 2570 insertions(+) create mode 100644 adaptors/modbus/Dockerfile create mode 100644 adaptors/modbus/Dockerfile.dapper create mode 100644 adaptors/modbus/Makefile create mode 100644 adaptors/modbus/README-cn.md create mode 100644 adaptors/modbus/README.md create mode 100644 adaptors/modbus/api/v1alpha1/groupversion_info.go create mode 100644 adaptors/modbus/api/v1alpha1/modbusdevice_types.go create mode 100644 adaptors/modbus/api/v1alpha1/zz_generated.deepcopy.go create mode 100644 adaptors/modbus/cmd/modbus/main.go create mode 100644 adaptors/modbus/deploy/e2e/all_in_one.yaml create mode 100644 adaptors/modbus/deploy/e2e/dl_modbus_rtu.yaml create mode 100644 adaptors/modbus/deploy/e2e/dl_modbus_tcp.yaml create mode 100644 adaptors/modbus/deploy/manifests/crd/base/devices.edge.cattle.io_modbusdevices.yaml create mode 100644 adaptors/modbus/deploy/manifests/crd/kustomization.yaml create mode 100644 adaptors/modbus/deploy/manifests/overlays/default/kustomization.yaml create mode 100644 adaptors/modbus/deploy/manifests/rbac/kustomization.yaml create mode 100644 adaptors/modbus/deploy/manifests/rbac/role.yaml create mode 100644 adaptors/modbus/deploy/manifests/rbac/role_binding.yaml create mode 100644 adaptors/modbus/deploy/manifests/workload/daemonset.yaml create mode 100644 adaptors/modbus/deploy/manifests/workload/kustomization.yaml create mode 100644 adaptors/modbus/hack/lib/constant.sh create mode 100755 adaptors/modbus/hack/make-rules/adaptor.sh create mode 100644 adaptors/modbus/pkg/adaptor/service.go create mode 100644 adaptors/modbus/pkg/modbus/modbus.go create mode 100644 adaptors/modbus/pkg/physical/converter.go create mode 100644 adaptors/modbus/pkg/physical/converter_test.go create mode 100644 adaptors/modbus/pkg/physical/device.go create mode 100644 adaptors/modbus/pkg/physical/handler.go create mode 100644 adaptors/modbus/pkg/physical/parameters.go create mode 100644 adaptors/modbus/test/deploy_rtu.md diff --git a/adaptors/modbus/Dockerfile b/adaptors/modbus/Dockerfile new file mode 100644 index 00000000..e7681350 --- /dev/null +++ b/adaptors/modbus/Dockerfile @@ -0,0 +1,12 @@ +FROM --platform=$TARGETPLATFORM scratch + +# NB(thxCode): automatic platform ARGs, ref to: +# - https://docs.docker.com/engine/reference/builder/#automatic-platform-args-in-the-global-scope +ARG TARGETPLATFORM +ARG TARGETOS +ARG TARGETARCH + +WORKDIR / +VOLUME /var/lib/octopus/adaptors +COPY bin/modbus_${TARGETOS}_${TARGETARCH} /modbus +ENTRYPOINT ["/modbus"] diff --git a/adaptors/modbus/Dockerfile.dapper b/adaptors/modbus/Dockerfile.dapper new file mode 100644 index 00000000..ee211242 --- /dev/null +++ b/adaptors/modbus/Dockerfile.dapper @@ -0,0 +1,36 @@ +FROM golang:1.13.9-buster +RUN apt-get update && \ + apt-get install -y xz-utils unzip + +# -- for make rules +## install docker +RUN curl -sSfL "https://get.docker.com" | sh -s VERSION=19.03; \ + docker --version +## install kubectl +RUN curl -fL "https://storage.googleapis.com/kubernetes-release/release/v1.17.2/bin/$(go env GOOS)/$(go env GOARCH)/kubectl" -o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl; \ + kubectl version --short --client +## install golangci-lint +RUN if [ "$(go env GOARCH)" = "amd64" ]; then \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v1.24.0; \ + golangci-lint --version; \ + fi +## install controller-gen +RUN if [ "$(go env GOARCH)" = "amd64" ]; then \ + GO111MODULE=on go get sigs.k8s.io/controller-tools/cmd/controller-gen@v0.2.5; \ + controller-gen --version; \ + fi +# -- for make rules + +# -- for dapper +ENV DAPPER_RUN_ARGS --privileged --network host +ENV GO111MODULE=off +ENV CROSS=false +ENV DAPPER_ENV CROSS LOCAL_CLUSTER_KIND DOCKER_USERNAME DOCKER_PASSWORD WITHOUT_MANIFEST ONLY_MANIFEST IGNORE_MISSING DRONE_TAG REPO TAG OS ARCH IMAGE_NAME +ENV DAPPER_SOURCE /go/src/github.com/rancher/octopus/ +ENV DAPPER_OUTPUT ./adaptors/modbus/bin ./adaptors/modbus/dist ./adaptors/modbus/deploy ./adaptors/modbus/api +ENV DAPPER_DOCKER_SOCKET true +ENV HOME ${DAPPER_SOURCE} +# -- for dapper + +WORKDIR ${DAPPER_SOURCE} +ENTRYPOINT ["make", "-se", "adaptor"] diff --git a/adaptors/modbus/Makefile b/adaptors/modbus/Makefile new file mode 100644 index 00000000..5edc4012 --- /dev/null +++ b/adaptors/modbus/Makefile @@ -0,0 +1,49 @@ +SHELL := /bin/bash + +# Borrowed from https://stackoverflow.com/questions/18136918/how-to-get-current-relative-directory-of-your-makefile +curr_dir := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) + +# Borrowed from https://stackoverflow.com/questions/2214575/passing-arguments-to-make-run +rest_args := $(wordlist 2, $(words $(MAKECMDGOALS)), $(MAKECMDGOALS)) +$(eval $(rest_args):;@:) + +all: help + +help: + # Building process. + # + # Usage: + # make adaptor {adaptor-name} [only] + # + # Stage: + # a "stage" consists of serval actions, actions follow as below: + # generate -> mod -> lint -> build -> containerize -> deploy + # \ -> test -> verify -> e2e + # for convenience, the name of the "action" also represents the current "stage". + # choosing to execute a certain "stage" will execute all actions in the previous sequence. + # + # Actions: + # - generate, g : generate deployment manifests and code implementations via `controller-gen`. + # - mod, m : download code dependencies. + # - lint, l : verify code via `golangci-lint`, + # roll back to `go fmt` and `go vet` if the installation fails. + # - build, b : compile code. + # - package, p : package docker image. + # - deploy, d : push docker image. + # - test, t : run unit tests. + # - verify, v : run integration tests. + # - e2e, e : run e2e tests. + # only executing the corresponding "action" of a "stage" needs the `only` suffix. + # integrate with dapper via `BY=dapper`. + # + # Example: + # - make adaptor modbus : execute `build` stage for "modbus" adaptor. + # - make adaptor modbus test : execute `test` stage for "modbus" adaptor. + # - make adaptor modbus build only : only execute `build` action for "modbus" adaptor, during `build` stage. + @echo + +make_rules := $(shell ls $(curr_dir)/hack/make-rules | sed 's/.sh//g') +$(make_rules): + @$(curr_dir)/hack/make-rules/$@.sh $(rest_args) + +.PHONY: $(make_rules) test deploy pkg diff --git a/adaptors/modbus/README-cn.md b/adaptors/modbus/README-cn.md new file mode 100644 index 00000000..a8b7a798 --- /dev/null +++ b/adaptors/modbus/README-cn.md @@ -0,0 +1,108 @@ +# Modbus Adaptor + +## Introduction + +Adaptor的作用是连接和控制边缘设备与limb进行通信,Modbus Adaptor是对于使用modbus通信协议的设备的adaptor实现。 +Modbus Adaptor支持TCP和RTU通信协议,用户构建DeviceLink CRD指定相应参数,并添加到集群内,即可接入设备,实现对设备属性的读写。 + +## Modbus Protocol + +Modbus协议是一个master/slave架构的协议。 +有一个节点是master节点,其他使用Modbus协议参与通信的节点是slave节点。 +每一个slave设备都有一个唯一的地址。 +所有设备都会收到命令,但只有指定位置的设备会执行及回应指令(地址0例外,指定地址0的指令是广播指令,所有收到指令的设备都会运行,不过不回应指令)。 +在Modbus Adaptor中,adaptor作为master连接modbus slave设备。 + +所有的Modbus命令包含了检查码,以确定到达的命令没有被破坏。 +基本的ModBus命令能指令一个RTU改变它的寄存器的某个值,控制或者读取一个I/O端口,以及指挥设备回送一个或者多个其寄存器中的数据。 + +## Registers Operation +Modbus设备有四种寄存器,可读可写的线圈寄存器(位操作),保持寄存器(字操作),和只读的离散输入寄存器(位操作),输入寄存器(字操作)。 + +**线圈寄存器**:可以类比为开关量,每一个bit对应一个信号的开关状态。所以一个byte可以同时控制8路的信号。比如控制外部8路io的高低。 线圈寄存器支持读也支持写。 + +**离散输入寄存器**:离散输入寄存器就相当于线圈寄存器的只读模式,也是每个bit表示一个开关量,而只能读取输入的开关信号,是不能写的。 + +**保持寄存器**:单位不再是bit而是两个byte,可以存放具体的数据量的,并且是可读写的。比如设置时间年月日,不但可以写也可以读出来现在的时间。 + +**输入寄存器**:和保持寄存器类似,但也只支持读而不能写。一个寄存器也是占据两个byte的空间。类比我我通过读取输入寄存器获取现在的AD采集值。 + +Modbus功能码可以分为位操作和字操作两类。位操作的最小单位为BIT,字操作的最小单位为两个字节。 + +**位操作指令**: 读线圈状态01H,读(离散)输入状态02H,写单个线圈06H和写多个线圈0FH。 + +**字操作指令**: 读保持寄存器03H,写单个寄存器06H,写多个保持寄存器10H。 + + +## DeviceLink CRD +定义设备链接(DeviceLink) +```yaml +apiVersion: edge.cattle.io/v1alpha1 +kind: DeviceLink +metadata: + name: modbus-tcp +spec: + adaptor: + node: edge-worker + name: adaptors.edge.cattle.io/modbus + model: + apiVersion: "devices.edge.cattle.io/v1alpha1" + kind: "ModbusDevice" + template: + metadata: + labels: + device: modbus-tcp + spec: + protocol: + tcp: + ip: 192.168.1.3 + port: 502 + slaveID: 1 + properties: + - name: temperature + description: data collection of temperature sensor + readOnly: false + visitor: + register: HoldingRegister + offset: 2 + quantity: 8 + value: "33.3" + dataType: float + - name: temperature-enable + description: enable data collection of temperature sensor + readOnly: false + visitor: + register: CoilRegister + offset: 2 + quantity: 1 + value: "true" + dataType: boolean + +``` + +### Parameters +#### TCP Config + +| Parameter | Description | Type | +|:--|:--|:--| +| ip | 设备的IP地址 | string +| port | 设备的端口 | int +| slaveId | 访问寄存器值时的标识字段 | int + +#### RTU Config + +| Parameter | Description | Type | Default | +|:--|:--|:--|:--| +| serialPort | 设备连接的串口,不同边缘节点操作系统下可选择不同的值。(e.g. /dev/ttyS0) | string | +| slaveId | 访问寄存器值时的标识字段 | int | +| baudRate | 每秒钟传送码元符号的个数,衡量数据传输速率的指标 | int | 19200 | +| dataBits | 衡量通信中实际数据位的参数 (5, 6, 7 or 8) | int | 8 | +| parity | 一种简单的校错方式,判断是否有噪声干扰通信或者是否存在传输和接收数据不同步 (N - None, E - Even, O - Odd) |string | E | +| stopBits | 用于表示单个数据包的最后一位 (1 or 2)|int| 1 | + +### Property Visitor +| Parameter | Description | Type | +|:--|:--|:--| +| register | 线圈寄存器 (CoilRegister)、离散输入寄存器 (DiscreteInputRegister)、保持寄存器 (HoldingRegister)或输入寄存器 (InputRegister)| string +| offset | 寄存器偏移地址 | int +| quantity | 寄存器的个数 | int \ No newline at end of file diff --git a/adaptors/modbus/README.md b/adaptors/modbus/README.md new file mode 100644 index 00000000..9a34996d --- /dev/null +++ b/adaptors/modbus/README.md @@ -0,0 +1,134 @@ +# Modbus Adaptor + +## Introduction + +Modbus Adaptor is used for connecting to and manipulating modbus devices on the edge. +Modbus Adaptor supports TCP and RTU protocol. + +## Registration Information + +| Versions | Register Name | Endpoint Socket | Available | +|:---:|:---:|:---:|:---:| +| `v1alpha1` | `adaptors.edge.cattle.io/modbus` | `modbus.socket` | * | + +## Support Model + +| Kind | Group | Version | Available | +|:---:|:---:|:---:|:---:| +| `ModbusDevice` | `devices.edge.cattle.io` | `v1alpha1` | * | + +## Support Platform + +| OS | Arch | +|:---:|:---| +| `linux` | `amd64` | +| `linux` | `arm` | +| `linux` | `arm64` | + +## Usage + +```shell script +kubectl apply -f ./deploy/e2e/all_in_one.yaml +``` + +## Authority + +Grant permissions to Octopus as below: + +```text + Resources Non-Resource URLs Resource Names Verbs + --------- ----------------- -------------- ----- + modbusdevices.devices.edge.cattle.io [] [] [create delete get list patch update watch] + modbusdevices.devices.edge.cattle.io/status [] [] [get patch update] +``` + +## Modbus Protocol + +Modbus is a master/slave protocol. +The device requesting the information is called the Modbus Master and the devices supplying information are Modbus Slaves. +In a standard Modbus network, there is one Master and up to 247 Slaves, each with a unique Slave Address from 1 to 247. +The Master can also write information to the Slaves. + +In Modbus Adaptor, the adaptor as the master connects to modbus slave devices。 + +## Registers Operation +**Coil Registers**: readable and writable, 1 bit (off/on) + +**Discrete Input Registers**: readable, 1 bit (off/on) + +**Input Registers**: readable, 16 bits (0 to 65,535), essentially measurements and statuses + +**Holding Registers**: readable and writable, 16 bits (0 to 65,535), essentially configuration values + +## DeviceLink CRD +example deviceLink CRD +```yaml +apiVersion: edge.cattle.io/v1alpha1 +kind: DeviceLink +metadata: + name: modbus-tcp +spec: + adaptor: + node: edge-worker + name: adaptors.edge.cattle.io/modbus + model: + apiVersion: "devices.edge.cattle.io/v1alpha1" + kind: "ModbusDevice" + template: + metadata: + labels: + device: modbus-tcp + spec: + protocol: + tcp: + ip: 192.168.1.3 + port: 502 + slaveID: 1 + properties: + - name: temperature + description: data collection of temperature sensor + readOnly: false + visitor: + register: HoldingRegister + offset: 2 + quantity: 8 + value: "33.3" + dataType: float + - name: temperature-enable + description: enable data collection of temperature sensor + readOnly: false + visitor: + register: CoilRegister + offset: 2 + quantity: 1 + value: "true" + dataType: boolean + +``` + +### Parameters +#### TCP Config + +| Parameter | Description | Type | +|:--|:--|:--| +| ip | ip address of the device | string +| port | tcp port of the device | int +| slaveId | slave id of the device | int + +#### RTU Config + +| Parameter | Description | Type | Default | +|:--|:--|:--|:--| +| serialPort | Device path (e.g. /dev/ttyS0) | string | +| slaveId | slave id of the device | int | +| baudRate | baud rate, a measurement of transmission speed | int | 19200 | +| dataBits | data bits (5, 6, 7 or 8) | int | 8 | +| parity | N - None, E - Even, O - Odd (default E) (The use of no parity requires 2 stop bits.) |string | E | +| stopBits | 1 or 2 |int| 1 | + +### Property Visitor +| Parameter | Description | Type | +|:--|:--|:--| +| register | CoilRegister, DiscreteInputRegister, HoldingRegister, or InputRegister | string +| offset | Offset indicates the starting register number to read/write data | int +| quantity | Limit number of registers to read/write | int \ No newline at end of file diff --git a/adaptors/modbus/api/v1alpha1/groupversion_info.go b/adaptors/modbus/api/v1alpha1/groupversion_info.go new file mode 100644 index 00000000..ac618f4d --- /dev/null +++ b/adaptors/modbus/api/v1alpha1/groupversion_info.go @@ -0,0 +1,35 @@ +/* + +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 v1alpha1 contains API Schema definitions for the edge v1alpha1 API group +// +kubebuilder:object:generate=true +// +groupName=devices.edge.cattle.io +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "devices.edge.cattle.io", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/adaptors/modbus/api/v1alpha1/modbusdevice_types.go b/adaptors/modbus/api/v1alpha1/modbusdevice_types.go new file mode 100644 index 00000000..831eb29d --- /dev/null +++ b/adaptors/modbus/api/v1alpha1/modbusdevice_types.go @@ -0,0 +1,140 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // Modbus protocol register types + ModbusRegisterTypeCoilRegister ModbusRegisterType = "CoilRegister" + ModbusRegisterTypeDiscreteInputRegister ModbusRegisterType = "DiscreteInputRegister" + ModbusRegisterTypeInputRegister ModbusRegisterType = "InputRegister" + ModbusRegisterTypeHoldingRegister ModbusRegisterType = "HoldingRegister" + + //Modbus property data types + PropertyDataTypeInt PropertyDataType = "int" + PropertyDataTypeString PropertyDataType = "string" + PropertyDataTypeFloat PropertyDataType = "float" + PropertyDataTypeBoolean PropertyDataType = "boolean" +) + +// The Modbus register type to read a device property. +// +kubebuilder:validation:Enum=CoilRegister;DiscreteInputRegister;InputRegister;HoldingRegister +type ModbusRegisterType string + +// The property data type. +// +kubebuilder:validation:Enum=float;int;string;boolean +type PropertyDataType string + +// ModbusDeviceSpec defines the desired state of ModbusDevice +type ModbusDeviceSpec struct { + ProtocolConfig *ModbusProtocolConfig `json:"protocol"` + Properties []DeviceProperty `json:"properties,omitempty"` +} + +// Only one of its members may be specified. +type ModbusProtocolConfig struct { + RTU *ModbusConfigRTU `json:"rtu,omitempty"` + TCP *ModbusConfigTCP `json:"tcp,omitempty"` +} + +type ModbusConfigTCP struct { + IP string `json:"ip"` + Port int `json:"port"` + SlaveID int `json:"slaveID"` +} + +type ModbusConfigRTU struct { + // Device path (/dev/ttyS0) + SerialPort string `json:"serialPort"` + SlaveID int `json:"slaveID"` + // Baud rate (default 19200) + BaudRate int `json:"baudRate,omitempty"` + // Data bits: 5, 6, 7 or 8 (default 8) + // +kubebuilder:validation:Enum=5;6;7;8 + DataBits int `json:"dataBits,omitempty"` + // The parity. N - None, E - Even, O - Odd, default E. + // +kubebuilder:validation:Enum=O;E;N + Parity string `json:"parity,omitempty"` + // Stop bits: 1 or 2 (default 1) + // +kubebuilder:validation:Enum=1;2 + StopBits int `json:"stopBits,omitempty"` +} + +// DeviceProperty describes an individual device property / attribute like temperature / humidity etc. +type DeviceProperty struct { + // The device property name. + Name string `json:"name"` + // The device property description. + Description string `json:"description,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + // PropertyDataType represents the type and data validation of the property. + DataType PropertyDataType `json:"dataType"` + Visitor PropertyVisitor `json:"visitor"` + Value string `json:"value,omitempty"` +} + +type PropertyVisitor struct { + // Type of register + Register ModbusRegisterType `json:"register"` + // Offset indicates the starting register number to read/write data. + Offset uint16 `json:"offset"` + // The quantity of registers + Quantity uint16 `json:"quantity"` + OrderOfOperations []ModbusOperations `json:"orderOfOperations,omitempty"` +} + +type ModbusOperations struct { + OperationType ArithOperationType `json:"operationType,omitempty"` + OperationValue string `json:"operationValue,omitempty"` +} + +// +kubebuilder:validation:Enum=Add;Subtract;Multiply;Divide +type ArithOperationType string + +const ( + OperationAdd ArithOperationType = "Add" + OperationSubtract ArithOperationType = "Subtract" + OperationMultiply ArithOperationType = "Multiply" + OperationDivide ArithOperationType = "Divide" +) + +// ModbusDeviceStatus defines the observed state of ModbusDevice +type ModbusDeviceStatus struct { + Properties []StatusProperties `json:"properties,omitempty"` +} + +type StatusProperties struct { + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` + DataType PropertyDataType `json:"dataType,omitempty"` + UpdatedAt metav1.Time `json:"updatedAt,omitempty"` +} + +// +kubebuilder:object:root=true +// +k8s:openapi-gen=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="IP",type="string",JSONPath=".spec.protocol.tcp.ip" +// +kubebuilder:printcolumn:name="PORT",type="integer",JSONPath=".spec.protocol.tcp.port" +// +kubebuilder:printcolumn:name="SERIAL PORT",type="string",JSONPath=".spec.protocol.rtu.serialPort" +// ModbusDevice is the Schema for the modbus device API +type ModbusDevice struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ModbusDeviceSpec `json:"spec,omitempty"` + Status ModbusDeviceStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true +// ModbusDeviceList contains a list of modbus devices +type ModbusDeviceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []ModbusDevice `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ModbusDevice{}, &ModbusDeviceList{}) +} diff --git a/adaptors/modbus/api/v1alpha1/zz_generated.deepcopy.go b/adaptors/modbus/api/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 00000000..e7098360 --- /dev/null +++ b/adaptors/modbus/api/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,254 @@ +// +build !ignore_autogenerated + +/* + +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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DeviceProperty) DeepCopyInto(out *DeviceProperty) { + *out = *in + in.Visitor.DeepCopyInto(&out.Visitor) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeviceProperty. +func (in *DeviceProperty) DeepCopy() *DeviceProperty { + if in == nil { + return nil + } + out := new(DeviceProperty) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModbusConfigRTU) DeepCopyInto(out *ModbusConfigRTU) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModbusConfigRTU. +func (in *ModbusConfigRTU) DeepCopy() *ModbusConfigRTU { + if in == nil { + return nil + } + out := new(ModbusConfigRTU) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModbusConfigTCP) DeepCopyInto(out *ModbusConfigTCP) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModbusConfigTCP. +func (in *ModbusConfigTCP) DeepCopy() *ModbusConfigTCP { + if in == nil { + return nil + } + out := new(ModbusConfigTCP) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModbusDevice) DeepCopyInto(out *ModbusDevice) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModbusDevice. +func (in *ModbusDevice) DeepCopy() *ModbusDevice { + if in == nil { + return nil + } + out := new(ModbusDevice) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ModbusDevice) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModbusDeviceList) DeepCopyInto(out *ModbusDeviceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ModbusDevice, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModbusDeviceList. +func (in *ModbusDeviceList) DeepCopy() *ModbusDeviceList { + if in == nil { + return nil + } + out := new(ModbusDeviceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ModbusDeviceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModbusDeviceSpec) DeepCopyInto(out *ModbusDeviceSpec) { + *out = *in + if in.ProtocolConfig != nil { + in, out := &in.ProtocolConfig, &out.ProtocolConfig + *out = new(ModbusProtocolConfig) + (*in).DeepCopyInto(*out) + } + if in.Properties != nil { + in, out := &in.Properties, &out.Properties + *out = make([]DeviceProperty, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModbusDeviceSpec. +func (in *ModbusDeviceSpec) DeepCopy() *ModbusDeviceSpec { + if in == nil { + return nil + } + out := new(ModbusDeviceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModbusDeviceStatus) DeepCopyInto(out *ModbusDeviceStatus) { + *out = *in + if in.Properties != nil { + in, out := &in.Properties, &out.Properties + *out = make([]StatusProperties, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModbusDeviceStatus. +func (in *ModbusDeviceStatus) DeepCopy() *ModbusDeviceStatus { + if in == nil { + return nil + } + out := new(ModbusDeviceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModbusOperations) DeepCopyInto(out *ModbusOperations) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModbusOperations. +func (in *ModbusOperations) DeepCopy() *ModbusOperations { + if in == nil { + return nil + } + out := new(ModbusOperations) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ModbusProtocolConfig) DeepCopyInto(out *ModbusProtocolConfig) { + *out = *in + if in.RTU != nil { + in, out := &in.RTU, &out.RTU + *out = new(ModbusConfigRTU) + **out = **in + } + if in.TCP != nil { + in, out := &in.TCP, &out.TCP + *out = new(ModbusConfigTCP) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ModbusProtocolConfig. +func (in *ModbusProtocolConfig) DeepCopy() *ModbusProtocolConfig { + if in == nil { + return nil + } + out := new(ModbusProtocolConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PropertyVisitor) DeepCopyInto(out *PropertyVisitor) { + *out = *in + if in.OrderOfOperations != nil { + in, out := &in.OrderOfOperations, &out.OrderOfOperations + *out = make([]ModbusOperations, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PropertyVisitor. +func (in *PropertyVisitor) DeepCopy() *PropertyVisitor { + if in == nil { + return nil + } + out := new(PropertyVisitor) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StatusProperties) DeepCopyInto(out *StatusProperties) { + *out = *in + in.UpdatedAt.DeepCopyInto(&out.UpdatedAt) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatusProperties. +func (in *StatusProperties) DeepCopy() *StatusProperties { + if in == nil { + return nil + } + out := new(StatusProperties) + in.DeepCopyInto(out) + return out +} diff --git a/adaptors/modbus/cmd/modbus/main.go b/adaptors/modbus/cmd/modbus/main.go new file mode 100644 index 00000000..df7574ef --- /dev/null +++ b/adaptors/modbus/cmd/modbus/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/rancher/octopus/adaptors/modbus/pkg/modbus" + "github.com/rancher/octopus/pkg/util/version/verflag" +) + +const ( + name = "modbus" + description = `` +) + +func newCommand() *cobra.Command { + var c = &cobra.Command{ + Use: name, + Long: description, + RunE: func(cmd *cobra.Command, args []string) error { + verflag.PrintAndExitIfRequested(name) + return modbus.Run() + }, + } + verflag.AddFlags(c.Flags()) + return c +} + +func main() { + var c = newCommand() + if err := c.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/adaptors/modbus/deploy/e2e/all_in_one.yaml b/adaptors/modbus/deploy/e2e/all_in_one.yaml new file mode 100644 index 00000000..f2f9654d --- /dev/null +++ b/adaptors/modbus/deploy/e2e/all_in_one.yaml @@ -0,0 +1,302 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.2.5 + devices.edge.cattle.io/enable: "true" + devices.edge.cattle.io/status-property: '{"name":"string","dataType":"string","value":"string","updatedAt":"date"}' + creationTimestamp: null + labels: + app.kubernetes.io/name: octopus-adaptor-modbus + app.kubernetes.io/version: master + name: modbusdevices.devices.edge.cattle.io +spec: + group: devices.edge.cattle.io + names: + kind: ModbusDevice + listKind: ModbusDeviceList + plural: modbusdevices + singular: modbusdevice + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + - jsonPath: .spec.protocol.tcp.ip + name: IP + type: string + - jsonPath: .spec.protocol.tcp.port + name: PORT + type: integer + - jsonPath: .spec.protocol.rtu.serialPort + name: SERIAL PORT + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: ModbusDevice is the Schema for the modbus device API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: ModbusDeviceSpec defines the desired state of ModbusDevice + properties: + properties: + items: + description: DeviceProperty describes an individual device property + / attribute like temperature / humidity etc. + properties: + dataType: + description: PropertyDataType represents the type and data validation + of the property. + enum: + - float + - int + - string + - boolean + type: string + description: + description: The device property description. + type: string + name: + description: The device property name. + type: string + readOnly: + type: boolean + value: + type: string + visitor: + properties: + offset: + description: Offset indicates the starting register number + to read/write data. + type: integer + orderOfOperations: + items: + properties: + operationType: + enum: + - Add + - Subtract + - Multiply + - Divide + type: string + operationValue: + type: string + type: object + type: array + quantity: + description: The quantity of registers + type: integer + register: + description: Type of register + enum: + - CoilRegister + - DiscreteInputRegister + - InputRegister + - HoldingRegister + type: string + required: + - offset + - quantity + - register + type: object + required: + - dataType + - name + - visitor + type: object + type: array + protocol: + description: Only one of its members may be specified. + properties: + rtu: + properties: + baudRate: + description: Baud rate (default 19200) + type: integer + dataBits: + description: 'Data bits: 5, 6, 7 or 8 (default 8)' + enum: + - 5 + - 6 + - 7 + - 8 + type: integer + parity: + description: The parity. N - None, E - Even, O - Odd, default + E. + enum: + - O + - E + - "N" + type: string + serialPort: + description: Device path (/dev/ttyS0) + type: string + slaveID: + type: integer + stopBits: + description: 'Stop bits: 1 or 2 (default 1)' + enum: + - 1 + - 2 + type: integer + required: + - serialPort + - slaveID + type: object + tcp: + properties: + ip: + type: string + port: + type: integer + slaveID: + type: integer + required: + - ip + - port + - slaveID + type: object + type: object + required: + - protocol + type: object + status: + description: ModbusDeviceStatus defines the observed state of ModbusDevice + properties: + properties: + items: + properties: + dataType: + description: The property data type. + enum: + - float + - int + - string + - boolean + type: string + name: + type: string + updatedAt: + format: date-time + type: string + value: + type: string + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/name: octopus-adaptor-modbus + app.kubernetes.io/version: master + name: octopus-adaptor-modbus-manager-role +rules: +- apiGroups: + - devices.edge.cattle.io + resources: + - modbusdevices + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - devices.edge.cattle.io + resources: + - modbusdevices/status + verbs: + - get + - patch + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/component: rbac + app.kubernetes.io/name: octopus-adaptor-modbus + app.kubernetes.io/version: master + name: octopus-adaptor-modbus-manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: octopus-adaptor-modbus-manager-role +subjects: +- kind: ServiceAccount + name: default + namespace: octopus-system +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + labels: + app.kubernetes.io/component: adaptor + app.kubernetes.io/name: octopus-adaptor-modbus + app.kubernetes.io/version: master + name: octopus-adaptor-modbus-adaptor + namespace: octopus-system +spec: + selector: + matchLabels: + app.kubernetes.io/component: adaptor + app.kubernetes.io/name: octopus-adaptor-modbus + app.kubernetes.io/version: master + template: + metadata: + labels: + app.kubernetes.io/component: adaptor + app.kubernetes.io/name: octopus-adaptor-modbus + app.kubernetes.io/version: master + spec: + containers: + - image: rancher/octopus-adaptor-modbus:master + name: octopus + securityContext: + privileged: true + volumeMounts: + - mountPath: /var/lib/octopus/adaptors/ + name: sockets + - mountPath: /dev + name: dev + nodeSelector: + beta.kubernetes.io/os: linux + volumes: + - hostPath: + path: /var/lib/octopus/adaptors/ + type: DirectoryOrCreate + name: sockets + - hostPath: + path: /dev + name: dev diff --git a/adaptors/modbus/deploy/e2e/dl_modbus_rtu.yaml b/adaptors/modbus/deploy/e2e/dl_modbus_rtu.yaml new file mode 100644 index 00000000..c91449ec --- /dev/null +++ b/adaptors/modbus/deploy/e2e/dl_modbus_rtu.yaml @@ -0,0 +1,39 @@ +apiVersion: edge.cattle.io/v1alpha1 +kind: DeviceLink +metadata: + name: modbus-rtu +spec: + adaptor: + node: edge-worker + name: adaptors.edge.cattle.io/modbus + parameters: + syncInterval: 5 + timout: 10 + model: + apiVersion: "devices.edge.cattle.io/v1alpha1" + kind: "ModbusDevice" + template: + metadata: + labels: + device: modbus-rtu + spec: + protocol: + rtu: + serialPort: /dev/tty.usbserial-1410 + slaveID: 1 + parity: "N" + stopBits: 1 + dataBits: 8 + baudRate: 9600 + properties: + - name: temperature + description: data collection of temperature sensor + readOnly: true + visitor: + register: HoldingRegister + offset: 0 + quantity: 1 + orderOfOperations: + - operationType: Divide + operationValue: "10" + dataType: float diff --git a/adaptors/modbus/deploy/e2e/dl_modbus_tcp.yaml b/adaptors/modbus/deploy/e2e/dl_modbus_tcp.yaml new file mode 100644 index 00000000..9153cb60 --- /dev/null +++ b/adaptors/modbus/deploy/e2e/dl_modbus_tcp.yaml @@ -0,0 +1,43 @@ +apiVersion: edge.cattle.io/v1alpha1 +kind: DeviceLink +metadata: + name: modbus-tcp +spec: + adaptor: + node: k3s + name: adaptors.edge.cattle.io/modbus + parameters: + syncInterval: 5 + timout: 10 + model: + apiVersion: "devices.edge.cattle.io/v1alpha1" + kind: "ModbusDevice" + template: + metadata: + labels: + device: modbus-tcp + spec: + protocol: + tcp: + ip: 192.168.1.3 + port: 502 + slaveID: 1 + properties: + - name: temperature-enable + description: enable data collection of temperature sensor + readOnly: false + visitor: + register: CoilRegister + offset: 2 + quantity: 1 + value: "true" + dataType: boolean + - name: temperature + description: enable data collection of temperature sensor + readOnly: false + visitor: + register: HoldingRegister + offset: 2 + quantity: 8 + value: "33.3" + dataType: float \ No newline at end of file diff --git a/adaptors/modbus/deploy/manifests/crd/base/devices.edge.cattle.io_modbusdevices.yaml b/adaptors/modbus/deploy/manifests/crd/base/devices.edge.cattle.io_modbusdevices.yaml new file mode 100644 index 00000000..c7795d9d --- /dev/null +++ b/adaptors/modbus/deploy/manifests/crd/base/devices.edge.cattle.io_modbusdevices.yaml @@ -0,0 +1,208 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.2.5 + creationTimestamp: null + name: modbusdevices.devices.edge.cattle.io +spec: + group: devices.edge.cattle.io + names: + kind: ModbusDevice + listKind: ModbusDeviceList + plural: modbusdevices + singular: modbusdevice + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + - jsonPath: .spec.protocol.tcp.ip + name: IP + type: string + - jsonPath: .spec.protocol.tcp.port + name: PORT + type: integer + - jsonPath: .spec.protocol.rtu.serialPort + name: SERIAL PORT + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: ModbusDevice is the Schema for the modbus device API + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: ModbusDeviceSpec defines the desired state of ModbusDevice + properties: + properties: + items: + description: DeviceProperty describes an individual device property + / attribute like temperature / humidity etc. + properties: + dataType: + description: PropertyDataType represents the type and data validation + of the property. + enum: + - float + - int + - string + - boolean + type: string + description: + description: The device property description. + type: string + name: + description: The device property name. + type: string + readOnly: + type: boolean + value: + type: string + visitor: + properties: + offset: + description: Offset indicates the starting register number + to read/write data. + type: integer + orderOfOperations: + items: + properties: + operationType: + enum: + - Add + - Subtract + - Multiply + - Divide + type: string + operationValue: + type: string + type: object + type: array + quantity: + description: The quantity of registers + type: integer + register: + description: Type of register + enum: + - CoilRegister + - DiscreteInputRegister + - InputRegister + - HoldingRegister + type: string + required: + - offset + - quantity + - register + type: object + required: + - dataType + - name + - visitor + type: object + type: array + protocol: + description: Only one of its members may be specified. + properties: + rtu: + properties: + baudRate: + description: Baud rate (default 19200) + type: integer + dataBits: + description: 'Data bits: 5, 6, 7 or 8 (default 8)' + enum: + - 5 + - 6 + - 7 + - 8 + type: integer + parity: + description: The parity. N - None, E - Even, O - Odd, default + E. + enum: + - O + - E + - "N" + type: string + serialPort: + description: Device path (/dev/ttyS0) + type: string + slaveID: + type: integer + stopBits: + description: 'Stop bits: 1 or 2 (default 1)' + enum: + - 1 + - 2 + type: integer + required: + - serialPort + - slaveID + type: object + tcp: + properties: + ip: + type: string + port: + type: integer + slaveID: + type: integer + required: + - ip + - port + - slaveID + type: object + type: object + required: + - protocol + type: object + status: + description: ModbusDeviceStatus defines the observed state of ModbusDevice + properties: + properties: + items: + properties: + dataType: + description: The property data type. + enum: + - float + - int + - string + - boolean + type: string + name: + type: string + updatedAt: + format: date-time + type: string + value: + type: string + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: [] + storedVersions: [] diff --git a/adaptors/modbus/deploy/manifests/crd/kustomization.yaml b/adaptors/modbus/deploy/manifests/crd/kustomization.yaml new file mode 100644 index 00000000..a5c77626 --- /dev/null +++ b/adaptors/modbus/deploy/manifests/crd/kustomization.yaml @@ -0,0 +1,6 @@ +commonAnnotations: + devices.edge.cattle.io/enable: "true" + devices.edge.cattle.io/status-property: '{"name":"string","dataType":"string","value":"string","updatedAt":"date"}' + +resources: + - base/devices.edge.cattle.io_modbusdevices.yaml diff --git a/adaptors/modbus/deploy/manifests/overlays/default/kustomization.yaml b/adaptors/modbus/deploy/manifests/overlays/default/kustomization.yaml new file mode 100644 index 00000000..8f5b013e --- /dev/null +++ b/adaptors/modbus/deploy/manifests/overlays/default/kustomization.yaml @@ -0,0 +1,25 @@ +# Adds namespace to all resources. +namespace: octopus-system + +# Value of this field is prepended to the +# names of all resources, e.g. a deployment named +# "wordpress" becomes "alices-wordpress". +# Note that it should also match with the prefix (text before '-') of the namespace +# field above. +namePrefix: octopus-adaptor-modbus- + +# Labels to add to all resources and selectors. +commonLabels: + app.kubernetes.io/name: "octopus-adaptor-modbus" + app.kubernetes.io/version: "master" + +## Images to overwrite the default images. +images: + - name: rancher/octopus-adaptor-modbus + newName: rancher/octopus-adaptor-modbus + newTag: master + +bases: + - ../../crd + - ../../rbac + - ../../workload diff --git a/adaptors/modbus/deploy/manifests/rbac/kustomization.yaml b/adaptors/modbus/deploy/manifests/rbac/kustomization.yaml new file mode 100644 index 00000000..f9b79dad --- /dev/null +++ b/adaptors/modbus/deploy/manifests/rbac/kustomization.yaml @@ -0,0 +1,6 @@ +commonLabels: + app.kubernetes.io/component: "rbac" + +resources: + - role.yaml + - role_binding.yaml diff --git a/adaptors/modbus/deploy/manifests/rbac/role.yaml b/adaptors/modbus/deploy/manifests/rbac/role.yaml new file mode 100644 index 00000000..14aa6edd --- /dev/null +++ b/adaptors/modbus/deploy/manifests/rbac/role.yaml @@ -0,0 +1,28 @@ + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + name: manager-role +rules: +- apiGroups: + - devices.edge.cattle.io + resources: + - modbusdevices + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - devices.edge.cattle.io + resources: + - modbusdevices/status + verbs: + - get + - patch + - update diff --git a/adaptors/modbus/deploy/manifests/rbac/role_binding.yaml b/adaptors/modbus/deploy/manifests/rbac/role_binding.yaml new file mode 100644 index 00000000..217ab207 --- /dev/null +++ b/adaptors/modbus/deploy/manifests/rbac/role_binding.yaml @@ -0,0 +1,14 @@ + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: manager-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: manager-role +subjects: + - kind: ServiceAccount + name: default + namespace: system diff --git a/adaptors/modbus/deploy/manifests/workload/daemonset.yaml b/adaptors/modbus/deploy/manifests/workload/daemonset.yaml new file mode 100644 index 00000000..6a8efc36 --- /dev/null +++ b/adaptors/modbus/deploy/manifests/workload/daemonset.yaml @@ -0,0 +1,38 @@ + +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + labels: + app.kubernetes.io/component: "adaptor" + name: adaptor + namespace: system +spec: + selector: + matchLabels: + app.kubernetes.io/component: "adaptor" + template: + metadata: + labels: + app.kubernetes.io/component: "adaptor" + spec: + nodeSelector: + beta.kubernetes.io/os: linux + containers: + - name: octopus + image: rancher/octopus-adaptor-modbus:latest + volumeMounts: + - mountPath: /var/lib/octopus/adaptors/ + name: sockets + - mountPath: /dev + name: dev + securityContext: + privileged: true + volumes: + - name: sockets + hostPath: + path: /var/lib/octopus/adaptors/ + type: DirectoryOrCreate + - name: dev + hostPath: + path: /dev diff --git a/adaptors/modbus/deploy/manifests/workload/kustomization.yaml b/adaptors/modbus/deploy/manifests/workload/kustomization.yaml new file mode 100644 index 00000000..0987901a --- /dev/null +++ b/adaptors/modbus/deploy/manifests/workload/kustomization.yaml @@ -0,0 +1,2 @@ +resources: + - daemonset.yaml diff --git a/adaptors/modbus/hack/lib/constant.sh b/adaptors/modbus/hack/lib/constant.sh new file mode 100644 index 00000000..35183625 --- /dev/null +++ b/adaptors/modbus/hack/lib/constant.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +readonly SUPPORTED_PLATFORMS=( + linux/amd64 + linux/arm + linux/arm64 +) \ No newline at end of file diff --git a/adaptors/modbus/hack/make-rules/adaptor.sh b/adaptors/modbus/hack/make-rules/adaptor.sh new file mode 100755 index 00000000..630faa39 --- /dev/null +++ b/adaptors/modbus/hack/make-rules/adaptor.sh @@ -0,0 +1,308 @@ +#!/usr/bin/env bash + +set -o errexit +set -o nounset +set -o pipefail + +CURR_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +# The root of the octopus directory +ROOT_DIR="$(cd "${CURR_DIR}/../.." && pwd -P)" +source "${ROOT_DIR}/hack/lib/init.sh" +source "${CURR_DIR}/hack/lib/constant.sh" + +mkdir -p "${CURR_DIR}/bin" +mkdir -p "${CURR_DIR}/dist" + +function generate() { + local adaptor="${1}" + + octopus::log::info "generating adaptor ${adaptor}..." + + octopus::log::info "generating objects" + rm -f "${CURR_DIR}/api/*/zz_generated*" + octopus::controller_gen::generate \ + object:headerFile="${ROOT_DIR}/hack/boilerplate.go.txt" \ + paths="${CURR_DIR}/api/..." + + octopus::log::info "generating manifests" + # generate crd + octopus::controller_gen::generate \ + crd:crdVersions=v1 \ + paths="${CURR_DIR}/api/..." \ + output:crd:dir="${CURR_DIR}/deploy/manifests/crd/base" + # generate rbac role + octopus::controller_gen::generate \ + rbac:roleName=manager-role \ + paths="${CURR_DIR}/pkg/..." \ + output:rbac:dir="${CURR_DIR}/deploy/manifests/rbac" + + octopus::log::info "merging manifests" + if ! octopus::kubectl::validate; then + octopus::log::fatal "kubectl hasn't been installed" + fi + kubectl kustomize "${CURR_DIR}/deploy/manifests/overlays/default" \ + >"${CURR_DIR}/deploy/e2e/all_in_one.yaml" + + octopus::log::info "...done" +} + +function mod() { + [[ "${2:-}" != "only" ]] && generate "$@" + local adaptor="${1}" + + # the adaptor is sharing the vendor with root + pushd "${ROOT_DIR}" >/dev/null || exist 1 + octopus::log::info "downloading dependencies for adaptor ${adaptor}..." + + if [[ "$(go env GO111MODULE)" == "off" ]]; then + octopus::log::warn "go mod has been disabled by GO111MODULE=off" + else + octopus::log::info "tidying" + go mod tidy + octopus::log::info "vending" + go mod vendor + fi + + octopus::log::info "...done" + popd >/dev/null || return +} + +function lint() { + [[ "${2:-}" != "only" ]] && mod "$@" + local adaptor="${1}" + + octopus::log::info "linting adaptor ${adaptor}..." + octopus::lint::generate "${CURR_DIR}/..." + octopus::log::info "...done" +} + +function build() { + [[ "${2:-}" != "only" ]] && lint "$@" + local adaptor="${1}" + + octopus::log::info "building adaptor ${adaptor}(${GIT_VERSION},${GIT_COMMIT},${GIT_TREE_STATE},${BUILD_DATE})..." + + local version_flags=" + -X k8s.io/client-go/pkg/version.gitVersion=${GIT_VERSION} + -X k8s.io/client-go/pkg/version.gitCommit=${GIT_COMMIT} + -X k8s.io/client-go/pkg/version.gitTreeState=${GIT_TREE_STATE} + -X k8s.io/client-go/pkg/version.buildDate=${BUILD_DATE}" + local flags=" + -w -s" + local ext_flags=" + -extldflags '-static'" + local os="${OS:-$(go env GOOS)}" + local arch="${ARCH:-$(go env GOARCH)}" + + local platforms + if [[ "${CROSS:-false}" == "true" ]]; then + octopus::log::info "crossed building" + platforms=("${SUPPORTED_PLATFORMS[@]}") + else + local os="${OS:-$(go env GOOS)}" + local arch="${ARCH:-$(go env GOARCH)}" + platforms=("${os}/${arch}") + fi + + for platform in "${platforms[@]}"; do + octopus::log::info "building ${platform}" + + local os_arch + IFS="/" read -r -a os_arch <<<"${platform}" + + local os=${os_arch[0]} + local arch=${os_arch[1]} + GOOS=${os} GOARCH=${arch} CGO_ENABLED=0 go build \ + -ldflags "${version_flags} ${flags} ${ext_flags}" \ + -o "${CURR_DIR}/bin/${adaptor}_${os}_${arch}" \ + "${CURR_DIR}/cmd/${adaptor}/main.go" + done + + octopus::log::info "...done" +} + +function package() { + [[ "${2:-}" != "only" ]] && build "$@" + local adaptor="${1}" + + octopus::log::info "packaging adaptor ${adaptor}..." + + local repo=${REPO:-rancher} + local image_name=${IMAGE_NAME:-octopus-adaptor-${adaptor}} + local tag=${TAG:-${GIT_VERSION}} + + local platforms + if [[ "${CROSS:-false}" == "true" ]]; then + octopus::log::info "crossed packaging" + platforms=("${SUPPORTED_PLATFORMS[@]}") + else + local os="${OS:-$(go env GOOS)}" + local arch="${ARCH:-$(go env GOARCH)}" + platforms=("${os}/${arch}") + fi + + pushd "${CURR_DIR}" >/dev/null 2>&1 + for platform in "${platforms[@]}"; do + if [[ "${platform}" =~ darwin/* ]]; then + octopus::log::fatal "package into Darwin OS image is unavailable, please use CROSS=true env to containerize multiple arch images or use OS=linux ARCH=amd64 env to containerize linux/amd64 image" + fi + + local image_tag="${repo}/${image_name}:${tag}-${platform////-}" + octopus::log::info "packaging ${image_tag}" + octopus::docker::build \ + --platform "${platform}" \ + -t "${image_tag}" . + done + popd >/dev/null 2>&1 + + octopus::log::info "...done" +} + +function deploy() { + [[ "${2:-}" != "only" ]] && package "$@" + local adaptor="${1}" + + octopus::log::info "deploying adaptor ${adaptor}..." + + local repo=${REPO:-rancher} + local image_name=${IMAGE_NAME:-octopus-adaptor-${adaptor}} + local tag=${TAG:-${GIT_VERSION}} + + local platforms + if [[ "${CROSS:-false}" == "true" ]]; then + octopus::log::info "crossed deploying" + platforms=("${SUPPORTED_PLATFORMS[@]}") + else + local os="${OS:-$(go env GOOS)}" + local arch="${ARCH:-$(go env GOARCH)}" + platforms=("${os}/${arch}") + fi + local images=() + for platform in "${platforms[@]}"; do + if [[ "${platform}" =~ darwin/* ]]; then + octopus::log::fatal "package into Darwin OS image is unavailable, please use CROSS=true env to containerize multiple arch images or use OS=linux ARCH=amd64 env to containerize linux/amd64 image" + fi + + images+=("${repo}/${image_name}:${tag}-${platform////-}") + done + + local only_manifest=${ONLY_MANIFEST:-false} + local without_manifest=${WITHOUT_MANIFEST:-false} + local ignore_missing=${IGNORE_MISSING:-false} + + # docker push + if [[ "${only_manifest}" == "false" ]]; then + octopus::docker::push "${images[@]}" + else + octopus::log::warn "deploying images has been stopped by ONLY_MANIFEST" + # execute manifest forcibly + without_manifest="false" + fi + + # docker manifest + if [[ "${without_manifest}" == "false" ]]; then + if [[ "${ignore_missing}" == "false" ]]; then + octopus::docker::manifest "${repo}/${image_name}:${tag}" "${images[@]}" + else + octopus::manifest_tool::push from-args \ + --ignore-missing \ + --target="${repo}/${image_name}:${tag}" \ + --template="${repo}/${image_name}:${tag}-OS-ARCH" \ + --platforms="$(octopus::util::join_array "," "${platforms[@]}")" + fi + + # generate tested yaml + local tmpfile + tmpfile=$(mktemp) + cp -f "${CURR_DIR}/deploy/e2e/all_in_one.yaml" "${CURR_DIR}/dist/octopus_adaptor_${adaptor}_all_in_one.yaml" + sed "s#app.kubernetes.io/version: master#app.kubernetes.io/version: ${tag}#g" \ + "${CURR_DIR}/dist/octopus_adaptor_${adaptor}_all_in_one.yaml" >"${tmpfile}" && mv "${tmpfile}" "${CURR_DIR}/dist/octopus_adaptor_${adaptor}_all_in_one.yaml" + sed "s#image: rancher/octopus-adaptor-${adaptor}:master#image: ${repo}/${image_name}:${tag}#g" \ + "${CURR_DIR}/dist/octopus_adaptor_${adaptor}_all_in_one.yaml" >"${tmpfile}" && mv "${tmpfile}" "${CURR_DIR}/dist/octopus_adaptor_${adaptor}_all_in_one.yaml" + else + octopus::log::warn "deploying manifest images has been stopped by WITHOUT_MANIFEST" + fi + + octopus::log::info "...done" +} + +function test() { + [[ "${2:-}" != "only" ]] && build "$@" + local adaptor="${1}" + + octopus::log::info "running unit tests for adaptor ${adaptor}..." + + local unit_test_targets=( + "${CURR_DIR}/api/..." + "${CURR_DIR}/cmd/..." + "${CURR_DIR}/pkg/..." + ) + + if [[ "${CROSS:-false}" == "true" ]]; then + octopus::log::warn "crossed test is not supported" + fi + + local os="${OS:-$(go env GOOS)}" + local arch="${ARCH:-$(go env GOARCH)}" + if [[ "${arch}" == "arm" ]]; then + # NB(thxCode): race detector doesn't support `arm` arch, ref to: + # - https://golang.org/doc/articles/race_detector.html#Supported_Systems + GOOS=${os} GOARCH=${arch} CGO_ENABLED=1 go test \ + -cover -coverprofile "${CURR_DIR}/dist/coverage_${adaptor}_${os}_${arch}.out" \ + "${unit_test_targets[@]}" + else + GOOS=${os} GOARCH=${arch} CGO_ENABLED=1 go test \ + -race \ + -cover -coverprofile "${CURR_DIR}/dist/coverage_${adaptor}_${os}_${arch}.out" \ + "${unit_test_targets[@]}" + fi + + octopus::log::info "...done" +} + +function verify() { + [[ "${2:-}" != "only" ]] && test "$@" + local adaptor="${1}" + + octopus::log::info "running integration tests for adaptor ${adaptor}..." + + octopus::log::info "...done" +} + +function e2e() { + [[ "${2:-}" != "only" ]] && verify "$@" + local adaptor="${1}" + + octopus::log::info "running E2E tests for adaptor ${adaptor}..." + + octopus::log::info "...done" +} + +function entry() { + local adaptor="${1:-}" + shift 1 + + local stage="${1:-build}" + shift $(($# > 0 ? 1 : 0)) + + octopus::log::info "make adaptor ${adaptor} ${stage} $*" + + case ${stage} in + g | gen | generate) generate "${adaptor}" "$@" ;; + m | mod) mod "${adaptor}" "$@" ;; + l | lint) lint "${adaptor}" "$@" ;; + b | build) build "${adaptor}" "$@" ;; + p | pkg | package) package "${adaptor}" "$@" ;; + d | dep | deploy) deploy "${adaptor}" "$@" ;; + t | test) test "${adaptor}" "$@" ;; + v | ver | verify) verify "${adaptor}" "$@" ;; + e | e2e) e2e "${adaptor}" "$@" ;; + *) octopus::log::fatal "unknown action '${stage}', select from generate,mod,lint,build,test,verify,package,deploy,e2e" ;; + esac +} + +if [[ ${BY:-} == "dapper" ]]; then + octopus::dapper::run -C "${ROOT_DIR}" -f "adaptors/${1}/Dockerfile.dapper" "$@" +else + entry "$@" +fi diff --git a/adaptors/modbus/pkg/adaptor/service.go b/adaptors/modbus/pkg/adaptor/service.go new file mode 100644 index 00000000..7e700b88 --- /dev/null +++ b/adaptors/modbus/pkg/adaptor/service.go @@ -0,0 +1,120 @@ +package adaptor + +import ( + jsoniter "github.com/json-iterator/go" + "github.com/rancher/octopus/adaptors/modbus/api/v1alpha1" + "github.com/rancher/octopus/adaptors/modbus/pkg/physical" + api "github.com/rancher/octopus/pkg/adaptor/api/v1alpha1" + "github.com/rancher/octopus/pkg/adaptor/connection" + "github.com/rancher/octopus/pkg/util/object" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + logr "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +var log = logr.NewDelegatingLogger(zap.New(zap.UseDevMode(true))) + +func NewService() *Service { + var scheme = k8sruntime.NewScheme() + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + + return &Service{ + scheme: scheme, + } +} + +type Service struct { + scheme *k8sruntime.Scheme +} + +func (s *Service) toJSON(in metav1.Object) []byte { + var out = unstructured.Unstructured{Object: make(map[string]interface{})} + // NB(thxCode) scheme conversion can keep the typemeta of an object, + // provided that the object type has been registered in scheme first. + _ = s.scheme.Convert(in, &out, nil) + var bytes, _ = out.MarshalJSON() + return bytes +} + +func (s *Service) Connect(server api.Connection_ConnectServer) error { + var device physical.Device + defer func() { + if device != nil { + device.Shutdown() + } + }() + + for { + var req, err = server.Recv() + if err != nil { + if !connection.IsClosed(err) { + log.Error(err, "Failed to receive connect request from Limb") + return status.Errorf(codes.Unknown, "shutdown connection as receiving error from Limb") + } + return nil + } + + // validate parameters + var parameters = physical.DefaultParameters() + if req.GetParameters() != nil { + if err := jsoniter.Unmarshal(req.GetParameters(), ¶meters); err != nil { + return status.Errorf(codes.InvalidArgument, "failed to unmarshal parameters: %v", err) + } + } + if err := parameters.Validate(); err != nil { + return status.Errorf(codes.InvalidArgument, "failed to validate parameters: %v", err) + } + + // validate device + var modbus v1alpha1.ModbusDevice + if err := jsoniter.Unmarshal(req.GetDevice(), &modbus); err != nil { + return status.Errorf(codes.InvalidArgument, "failed to unmarshal device: %v", err) + } + + // process device + if device == nil { + var deviceName = object.GetNamespacedName(&modbus) + var dataHandler = func(name types.NamespacedName, status v1alpha1.ModbusDeviceStatus) { + // send device by {name, namespace, status} tuple + var resp v1alpha1.ModbusDevice + resp.Namespace = name.Namespace + resp.Name = name.Name + resp.Status = status + + // convert device to json bytes + var respBytes = s.toJSON(&resp) + + // send device + if err := server.Send(&api.ConnectResponse{Device: respBytes}); err != nil { + if !connection.IsClosed(err) { + log.Error(err, "Failed to send response to connection") + } + } + } + + // create handler connecting to modbus physical device + var modbusHandler, err = physical.NewModbusHandler(modbus.Spec.ProtocolConfig, parameters.Timeout) + if err != nil { + log.Error(err, "Failed to connect to modbus device endpoint") + } + + device = physical.NewDevice( + log.WithValues("device", deviceName), + deviceName, + dataHandler, + modbusHandler, + parameters.SyncInterval, + modbus.Spec, + ) + + go device.On() + } + device.Configure(modbus.Spec) + } +} diff --git a/adaptors/modbus/pkg/modbus/modbus.go b/adaptors/modbus/pkg/modbus/modbus.go new file mode 100644 index 00000000..5993ccd5 --- /dev/null +++ b/adaptors/modbus/pkg/modbus/modbus.go @@ -0,0 +1,41 @@ +package modbus + +import ( + "golang.org/x/sync/errgroup" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/rancher/octopus/adaptors/modbus/pkg/adaptor" + api "github.com/rancher/octopus/pkg/adaptor/api/v1alpha1" + "github.com/rancher/octopus/pkg/adaptor/connection" + "github.com/rancher/octopus/pkg/adaptor/registration" + "github.com/rancher/octopus/pkg/util/critical" +) + +const ( + Name = "adaptors.edge.cattle.io/modbus" + Version = "v1alpha1" + Endpoint = "modbus.socket" +) + +// +kubebuilder:rbac:groups=devices.edge.cattle.io,resources=modbusdevices,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=devices.edge.cattle.io,resources=modbusdevices/status,verbs=get;update;patch + +func Run() error { + var stop = ctrl.SetupSignalHandler() + var ctx = critical.Context(stop) + eg, ctx := errgroup.WithContext(ctx) + stop = ctx.Done() + eg.Go(func() error { + // start adaptor to receive requests from Limb + return connection.Serve(Endpoint, adaptor.NewService(), stop) + }) + eg.Go(func() error { + // register adaptor to Limb + return registration.Register(ctx, api.RegisterRequest{ + Name: Name, + Version: Version, + Endpoint: Endpoint, + }) + }) + return eg.Wait() +} diff --git a/adaptors/modbus/pkg/physical/converter.go b/adaptors/modbus/pkg/physical/converter.go new file mode 100644 index 00000000..ccb447c5 --- /dev/null +++ b/adaptors/modbus/pkg/physical/converter.go @@ -0,0 +1,111 @@ +package physical + +import ( + "encoding/binary" + "errors" + "fmt" + "strconv" + + "github.com/rancher/octopus/adaptors/modbus/api/v1alpha1" + "github.com/sirupsen/logrus" +) + +// convert read data to string value +func ByteArrayToString(input []byte, dataType v1alpha1.PropertyDataType, operations []v1alpha1.ModbusOperations) (string, error) { + var result string + switch dataType { + case v1alpha1.PropertyDataTypeString: + result = string(input) + case v1alpha1.PropertyDataTypeInt, v1alpha1.PropertyDataTypeFloat: + arr, err := toTargetLength(input, 8) + if err != nil { + return "", err + } + value := binary.BigEndian.Uint64(arr) + converted := convertReadData(float64(value), operations) + result = fmt.Sprint(converted) + if dataType == v1alpha1.PropertyDataTypeInt { + result = fmt.Sprint(int(converted)) + } + case v1alpha1.PropertyDataTypeBoolean: + b := input[len(input)-1] + if b == 0 { + result = "false" + } else if b == 1 { + result = "true" + } else { + return "", errors.New("invalid boolean value") + } + default: + return "", errors.New("invalid data type") + } + return result, nil +} + +// convert written data to byte array according to datatype +func StringToByteArray(input string, dataType v1alpha1.PropertyDataType, length int) ([]byte, error) { + var data []byte + switch dataType { + case v1alpha1.PropertyDataTypeString: + data = []byte(input) + case v1alpha1.PropertyDataTypeBoolean: + b, err := strconv.ParseBool(input) + if err != nil { + return nil, err + } + if b == true { + data = []byte{1} + } else { + data = []byte{0} + } + case v1alpha1.PropertyDataTypeInt, v1alpha1.PropertyDataTypeFloat: + data = make([]byte, 8) + i, err := strconv.ParseUint(input, 10, 64) + if err != nil { + return nil, err + } + binary.BigEndian.PutUint64(data, i) + default: + return nil, errors.New("invalid data type") + } + return toTargetLength(data, length) +} + +// Pad or trim byte array to target length +// Short input gets zeros padded to the left, long input gets left bits trimmed +func toTargetLength(input []byte, length int) ([]byte, error) { + l := len(input) + if l == length { + return input, nil + } + if l > length { + if input[l-length-1] != 0 { + return nil, errors.New("input is longer than target length") + } + return input[l-length:], nil + } + tmp := make([]byte, length) + copy(tmp[length-l:], input) + return tmp, nil +} + +// ConvertReadData helps to convert the number read from the device into meaningful data +func convertReadData(result float64, operations []v1alpha1.ModbusOperations) float64 { + for _, executeOperation := range operations { + operationValue, err := strconv.ParseFloat(executeOperation.OperationValue, 64) + if err != nil { + logrus.Error(err, "failed to parse operation value") + } + switch executeOperation.OperationType { + case v1alpha1.OperationAdd: + result = result + operationValue + case v1alpha1.OperationSubtract: + result = result - operationValue + case v1alpha1.OperationMultiply: + result = result * operationValue + case v1alpha1.OperationDivide: + result = result / operationValue + } + } + return result +} diff --git a/adaptors/modbus/pkg/physical/converter_test.go b/adaptors/modbus/pkg/physical/converter_test.go new file mode 100644 index 00000000..3e851a22 --- /dev/null +++ b/adaptors/modbus/pkg/physical/converter_test.go @@ -0,0 +1,115 @@ +package physical + +import ( + "errors" + "reflect" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/rancher/octopus/adaptors/modbus/api/v1alpha1" +) + +func TestByteArrayToString(t *testing.T) { + type given struct { + input []byte + dataType v1alpha1.PropertyDataType + operations []v1alpha1.ModbusOperations + } + type expect struct { + result string + err error + } + var testCases = []struct { + given given + expect expect + }{ + { + given: given{ + input: []byte{0}, + dataType: "boolean", + }, + expect: expect{ + result: "false", + err: nil, + }, + }, + { + given: given{ + input: []byte{97}, + dataType: "string", + }, + expect: expect{ + result: "a", + err: nil, + }, + }, + { + given: given{ + input: nil, + dataType: "", + }, + expect: expect{ + result: "", + err: errors.New("invalid data type"), + }, + }, + } + for i, tc := range testCases { + var ret, err = ByteArrayToString(tc.given.input, tc.given.dataType, tc.given.operations) + if !reflect.DeepEqual(ret, tc.expect.result) { + t.Errorf("case %v: expected %s, got %s", i+1, spew.Sprintf("%#v", tc.expect), spew.Sprintf("%#v", ret)) + } + if !reflect.DeepEqual(err, tc.expect.err) { + t.Errorf("case %v: expected %s, got %s", i+1, spew.Sprintf("%#v", tc.expect), spew.Sprintf("%#v", ret)) + } + } +} + +func TestStringToByteArray(t *testing.T) { + type given struct { + input string + dataType v1alpha1.PropertyDataType + length int + } + type expect struct { + result []byte + err error + } + var testCases = []struct { + given given + expect expect + }{ + { + given: given{ + input: "3", + dataType: "int", + length: 1, + }, + expect: expect{ + result: []byte{3}, + err: nil, + }, + }, + { + given: given{ + input: "3", + dataType: "int", + length: 0, + }, + expect: expect{ + result: nil, + err: errors.New("input is longer than target length"), + }, + }, + } + + for i, tc := range testCases { + var ret, err = StringToByteArray(tc.given.input, tc.given.dataType, tc.given.length) + if !reflect.DeepEqual(ret, tc.expect.result) { + t.Errorf("case %v: expected %s, got %s", i+1, spew.Sprintf("%#v", tc.expect), spew.Sprintf("%#v", ret)) + } + if !reflect.DeepEqual(err, tc.expect.err) { + t.Errorf("case %v: expected %s, got %s", i+1, spew.Sprintf("%#v", tc.expect), spew.Sprintf("%#v", ret)) + } + } +} diff --git a/adaptors/modbus/pkg/physical/device.go b/adaptors/modbus/pkg/physical/device.go new file mode 100644 index 00000000..72fab7c1 --- /dev/null +++ b/adaptors/modbus/pkg/physical/device.go @@ -0,0 +1,242 @@ +package physical + +import ( + "strconv" + "sync" + "time" + + "github.com/go-logr/logr" + "github.com/goburrow/modbus" + "github.com/rancher/octopus/adaptors/modbus/api/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +type Device interface { + Configure(spec v1alpha1.ModbusDeviceSpec) + On() + Shutdown() +} + +func NewDevice(log logr.Logger, name types.NamespacedName, handler DataHandler, modbusHandler modbus.ClientHandler, syncInterval time.Duration, spec v1alpha1.ModbusDeviceSpec) Device { + return &device{ + log: log, + name: name, + handler: handler, + modbusHandler: modbusHandler, + syncInterval: syncInterval, + spec: spec, + } +} + +type device struct { + sync.Mutex + + stop chan struct{} + + log logr.Logger + name types.NamespacedName + handler DataHandler + + status v1alpha1.ModbusDeviceStatus + spec v1alpha1.ModbusDeviceSpec + + modbusHandler modbus.ClientHandler + syncInterval time.Duration +} + +func (d *device) Configure(spec v1alpha1.ModbusDeviceSpec) { + d.spec = spec + + properties := spec.Properties + for _, property := range properties { + if property.ReadOnly { + continue + } + if err := d.writeProperty(property.DataType, property.Visitor, property.Value); err != nil { + d.log.Error(err, "Error write property", "property", property.Name) + } + } + d.updateStatus(properties) +} + +func (d *device) On() { + if d.stop != nil { + close(d.stop) + } + d.stop = make(chan struct{}) + + var ticker = time.NewTicker(d.syncInterval * time.Second) + defer ticker.Stop() + + // periodically sync device status + for { + select { + case <-d.stop: + return + case <-ticker.C: + } + d.updateStatus(d.spec.Properties) + d.log.Info("Sync modbus device status", "device", d.name) + } +} + +func (d *device) Shutdown() { + if d.stop != nil { + close(d.stop) + } + d.log.Info("Closed connection") +} + +// write data of a property to coil register or holding register +func (d *device) writeProperty(dataType v1alpha1.PropertyDataType, visitor v1alpha1.PropertyVisitor, value string) error { + register := visitor.Register + quantity := visitor.Quantity + address := visitor.Offset + + client := modbus.NewClient(d.modbusHandler) + switch register { + case v1alpha1.ModbusRegisterTypeCoilRegister: + // one bit per register + l := quantity / 8 + if quantity%8 != 0 { + l++ + } + data, err := StringToByteArray(value, dataType, int(l)) + if err != nil { + d.log.Error(err, "Error converting data to byte array", "value", value) + return err + } + _, err = client.WriteMultipleCoils(address, quantity, data) + if err != nil { + d.log.Error(err, "Error writing property to register", "register", register, "data", data) + return err + } + case v1alpha1.ModbusRegisterTypeHoldingRegister: + // two bytes per register + data, err := StringToByteArray(value, dataType, int(quantity*2)) + if err != nil { + d.log.Error(err, "Error converting data to byte array", "value", value) + return err + } + _, err = client.WriteMultipleRegisters(address, quantity, data) + if err != nil { + d.log.Error(err, "Error writing property to register", "register", register, "data", data) + return err + } + } + return nil +} + +// read data of a property from its corresponding register +func (d *device) readProperty(dataType v1alpha1.PropertyDataType, visitor v1alpha1.PropertyVisitor) (string, error) { + register := visitor.Register + quantity := visitor.Quantity + address := visitor.Offset + + var result string + var data []byte + var err error + client := modbus.NewClient(d.modbusHandler) + switch register { + case v1alpha1.ModbusRegisterTypeCoilRegister: + data, err = client.ReadCoils(address, quantity) + if err != nil { + d.log.Error(err, "Error reading property from register", "register", register) + return "", err + } + case v1alpha1.ModbusRegisterTypeDiscreteInputRegister: + data, err = client.ReadDiscreteInputs(address, quantity) + if err != nil { + d.log.Error(err, "Error reading property from register", "register", register) + return "", err + } + + case v1alpha1.ModbusRegisterTypeHoldingRegister: + data, err = client.ReadHoldingRegisters(address, quantity) + if err != nil { + d.log.Error(err, "Error reading property from register", "register", register) + return "", err + } + + case v1alpha1.ModbusRegisterTypeInputRegister: + data, err = client.ReadInputRegisters(address, quantity) + if err != nil { + d.log.Error(err, "Error reading property from register", "register", register) + return "", err + } + + } + result, err = ByteArrayToString(data, dataType, visitor.OrderOfOperations) + if err != nil { + d.log.Error(err, "Error converting to string", "datatype", dataType) + } + return result, nil +} + +// update the properties from physical device to status +func (d *device) updateStatus(properties []v1alpha1.DeviceProperty) { + d.Lock() + defer d.Unlock() + for _, property := range properties { + value, err := d.readProperty(property.DataType, property.Visitor) + if err != nil { + d.log.Error(err, "Error sync device property", "property", property) + continue + } + d.updateStatusProperty(property.Name, value, property.DataType) + } + d.handler(d.name, d.status) +} + +func (d *device) updateStatusProperty(name, value string, dataType v1alpha1.PropertyDataType) { + sp := v1alpha1.StatusProperties{ + Name: name, + Value: value, + DataType: dataType, + UpdatedAt: metav1.Time{Time: time.Now()}, + } + found := false + for i, property := range d.status.Properties { + if property.Name == sp.Name { + d.status.Properties[i] = sp + found = true + break + } + } + if !found { + d.status.Properties = append(d.status.Properties, sp) + } +} +func NewModbusHandler(config *v1alpha1.ModbusProtocolConfig, timeout time.Duration) (modbus.ClientHandler, error) { + var TCPConfig = config.TCP + var RTUConfig = config.RTU + var handler modbus.ClientHandler + + if TCPConfig != nil { + endpoint := TCPConfig.IP + ":" + strconv.Itoa(TCPConfig.Port) + handlerTCP := modbus.NewTCPClientHandler(endpoint) + handlerTCP.Timeout = timeout * time.Second + handlerTCP.SlaveId = byte(TCPConfig.SlaveID) + if err := handlerTCP.Connect(); err != nil { + return nil, err + } + defer handlerTCP.Close() + handler = handlerTCP + } else if RTUConfig != nil { + serialPort := RTUConfig.SerialPort + handlerRTU := modbus.NewRTUClientHandler(serialPort) + handlerRTU.BaudRate = RTUConfig.BaudRate + handlerRTU.DataBits = RTUConfig.DataBits + handlerRTU.Parity = RTUConfig.Parity + handlerRTU.StopBits = RTUConfig.StopBits + handlerRTU.SlaveId = byte(RTUConfig.SlaveID) + handlerRTU.Timeout = timeout * time.Second + if err := handlerRTU.Connect(); err != nil { + return nil, err + } + defer handlerRTU.Close() + handler = handlerRTU + } + return handler, nil +} diff --git a/adaptors/modbus/pkg/physical/handler.go b/adaptors/modbus/pkg/physical/handler.go new file mode 100644 index 00000000..9ef2b22c --- /dev/null +++ b/adaptors/modbus/pkg/physical/handler.go @@ -0,0 +1,9 @@ +package physical + +import ( + "k8s.io/apimachinery/pkg/types" + + "github.com/rancher/octopus/adaptors/modbus/api/v1alpha1" +) + +type DataHandler func(name types.NamespacedName, status v1alpha1.ModbusDeviceStatus) diff --git a/adaptors/modbus/pkg/physical/parameters.go b/adaptors/modbus/pkg/physical/parameters.go new file mode 100644 index 00000000..5ccb9a19 --- /dev/null +++ b/adaptors/modbus/pkg/physical/parameters.go @@ -0,0 +1,28 @@ +package physical + +import ( + "time" +) + +const ( + defaultSyncInterval = 5 + defaultTimeout = 30 +) + +type Parameters struct { + SyncInterval time.Duration `json:"syncInterval,omitempty"` + Timeout time.Duration `json:"timeout,omitempty"` +} + +func (p *Parameters) Validate() error { + // nothing to do + + return nil +} + +func DefaultParameters() Parameters { + return Parameters{ + SyncInterval: defaultSyncInterval, + Timeout: defaultTimeout, + } +} diff --git a/adaptors/modbus/test/deploy_rtu.md b/adaptors/modbus/test/deploy_rtu.md new file mode 100644 index 00000000..0aefa45c --- /dev/null +++ b/adaptors/modbus/test/deploy_rtu.md @@ -0,0 +1,75 @@ +# Deploy Modbus RTU device + +## Deploy the modbusdevice model and run the Modbus Adaptor +```shell script +# deploy modbus adaptor and modbusdevice model +kubectl apply -f adaptors/modbus/deploy/e2e/all_in_one.yaml + +# confirm the modbus adaptor deployment +kubectl get daemonset octopus-adaptor-modbus-adaptor -n octopus-system +``` + +## Connect Modbus device to edge node +Please ensure that Modbus device is connected to your edge node. +If the device is not accessible to any node of the remote cluster, you can create a virtual machine on your local PC and join the cluster. + +### Create virtual machine and mount the device from host PC +For example, we can use [VirtualBox](https://www.virtualbox.org/wiki/Downloads) to create a virtual machine and join the cluster as a worker. +With the device connected to the local PC, we enable the serial port/USB as applicable on the virtual machine. + +## Configure the deviceLink with the serial port of the edge node +Find the mounted serial port of the device on the edge node from `/dev` directory. +Configure the path to the deviceLink's `spec.template.spec.protocol.rtu.serialPort` parameter. Remember to configure the correct edge node. +```yaml +apiVersion: edge.cattle.io/v1alpha1 +kind: DeviceLink +metadata: + name: modbus-rtu +spec: + adaptor: + node: test #node name + name: adaptors.edge.cattle.io/modbus + parameters: + syncInterval: 5 + timout: 10 + model: + apiVersion: "devices.edge.cattle.io/v1alpha1" + kind: "ModbusDevice" + template: + metadata: + labels: + device: modbus-rtu + spec: + protocol: + rtu: + serialPort: /dev/ttyUSB0 #serial port + slaveID: 1 + parity: "N" + stopBits: 1 + dataBits: 8 + baudRate: 9600 + properties: + - name: temperature + description: data collection of temperature sensor + readOnly: true + visitor: + register: HoldingRegister + offset: 0 + quantity: 1 + orderOfOperations: + - operationType: Divide + operationValue: "10" + dataType: float +``` + + ## Deploy the deviceLink +```shell script +# deploy a devicelink +kubectl apply -f adaptors/modbus/deploy/e2e/dl.yaml + +# confirm the state of devicelink +kubectl get dl modbus-rtu -n default + +# watch the device instance +kubectl get modbusdevice modbus-rtu -n default -w +``` \ No newline at end of file