Skip to content

Commit

Permalink
Reworking HTTP gateway
Browse files Browse the repository at this point in the history
- New way of adding routes, makes it cleaner
- Handlers per route type
- Adding the concept of a BaseGateway for easy extending
- Adding more tests
  • Loading branch information
samsamfire committed Jan 19, 2024
1 parent d51f4c8 commit 3e5f611
Show file tree
Hide file tree
Showing 10 changed files with 653 additions and 391 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
__pycache__/
*.py[cod]
*$py.class
*.vscode
*.vscode
node_modules
54 changes: 54 additions & 0 deletions gateway.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package canopen

// BaseGateway implements all the basic gateway features defined by CiA 309
// CiA 309 currently defines 4 types:
// CiA 309-2 : Modbus TCP
// CiA 309-3 : ASCII
// CiA 309-4 : Profinet
// CiA 309-5 : HTTP / Websocket
// Each gateway maps its own parsing logic to this base gateway
type BaseGateway struct {
network *Network
defaultNetwork uint16
defaultNodeId uint8
}

type GatewayVersion struct {
vendorId string
productCode string
revisionNumber string
serialNumber string
gatewayClass string
protocolVersion string
implementationClass string
}

// Set default network to use
func (gw *BaseGateway) SetDefaultNetwork(id uint16) error {
gw.defaultNetwork = id
return nil
}

// Set default node Id to use
func (gw *BaseGateway) SetDefaultNodeId(id uint8) error {
gw.defaultNodeId = id
return nil
}

// Get gateway version information
func (gw *BaseGateway) GetVersion() (GatewayVersion, error) {
return GatewayVersion{}, nil
}

// Broadcast nmt command to one or all nodes
func (gw *BaseGateway) NMTCommand(id uint8, command NMTCommand) error {
return gw.network.Command(id, command)
}

// Set SDO timeout
func (gw *BaseGateway) SetSDOTimeout(timeoutMs uint32) error {
// TODO : maybe add mutex in case ongoing transfer
gw.network.sdoClient.timeoutTimeUs = timeoutMs * 1000
gw.network.sdoClient.timeoutTimeBlockTransferUs = timeoutMs * 1000
return nil
}
22 changes: 13 additions & 9 deletions http_gateway_client.go → gateway_http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ func NewHTTPGatewayClient(baseURL string, apiVersion string, networkId int) *HTT
}

type HTTPGatewayResponse struct {
Sequence string `json:"sequence,omitempty"`
Sequence int `json:"sequence,omitempty"`
Data string `json:"data,omitempty"`
Length string `json:"length,omitempty"`
Length int `json:"length,omitempty"`
Response string `json:"response,omitempty"`
}

Expand Down Expand Up @@ -61,14 +61,9 @@ func (client *HTTPGatewayClient) get(uri string) (resp *HTTPGatewayResponse, err
log.Warnf("[HTTP][CLIENT][SEQ:%v] command resulted in error from server : %v", jsonRsp.Sequence, err)
return
}
sequenceNb, err := strconv.Atoi(jsonRsp.Sequence)
if err != nil {
log.Warnf("[HTTP][CLIENT] failed to get sequence number : %v", jsonRsp.Sequence)
return
}
// Check if sequence number is correct
if client.CurrentSequenceNb != sequenceNb {
log.Warnf("[HTTP][CLIENT][SEQ:%v] sequence number does not match expected value (%v)", sequenceNb, client.CurrentSequenceNb)
if client.CurrentSequenceNb != jsonRsp.Sequence {
log.Warnf("[HTTP][CLIENT][SEQ:%v] sequence number does not match expected value (%v)", jsonRsp.Sequence, client.CurrentSequenceNb)
}
return jsonRsp, nil
}
Expand All @@ -81,3 +76,12 @@ func (client *HTTPGatewayClient) Read(nodeId uint8, index uint16, subIndex uint8
}
return resp.Data, 0, nil
}

// Write via SDO
// func (client *HTTPGatewayClient) Write(nodeId uint8, index uint16, subIndex uint8, data string) error {
// resp, err := client.get(fmt.Sprintf("/%d/w/%d/%d", nodeId, index, subIndex))
// if err != nil {
// return
// }
// return resp.Data, 0, nil
// }
152 changes: 152 additions & 0 deletions gateway_http_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package canopen

import (
"encoding/hex"
"encoding/json"
"net/http"
"strconv"

log "github.com/sirupsen/logrus"
)

// A ResponseWriter but keeps track of any writes already done
// This is useful for custom processing in each handler
// But adding default behaviour for errors / success

type doneWriter struct {
http.ResponseWriter
done bool
}

func (w *doneWriter) WriteHeader(status int) {
w.done = true
w.ResponseWriter.WriteHeader(status)
}

func (w *doneWriter) Write(b []byte) (int, error) {
w.done = true
return w.ResponseWriter.Write(b)
}

// Gets an HTTP request and handles it according to CiA 309-5
type HTTPRequestHandler func(w doneWriter, req *HTTPGatewayRequest) error

func createNmtHandler(bg *BaseGateway, command NMTCommand) HTTPRequestHandler {
return func(w doneWriter, req *HTTPGatewayRequest) error {
switch req.nodeId {
case TOKEN_DEFAULT, TOKEN_NONE:
return bg.NMTCommand(bg.defaultNodeId, command)
case TOKEN_ALL:
return bg.NMTCommand(0, command)
default:
return bg.NMTCommand(uint8(req.nodeId), command)
}
}
}

// Can be used for specifying some routes that can be implemented in CiA 309
// But are not in this gateway
func handlerNotSupported(w doneWriter, req *HTTPGatewayRequest) error {
return ErrGwRequestNotSupported
}

// Handle a read
// This includes different type of handlers : SDO, PDO, ...
func (gw *HTTPGatewayServer) handlerRead(w doneWriter, req *HTTPGatewayRequest) error {
matchSDO := regSDO.FindStringSubmatch(req.command)
if len(matchSDO) >= 2 {
return gw.handlerSDORead(w, req, matchSDO)
}
matchPDO := regPDO.FindStringSubmatch(req.command)
if len(matchPDO) >= 2 {
return handlerNotSupported(w, req)
}
return ErrGwSyntaxError
}

func (gw *HTTPGatewayServer) handlerSDORead(w doneWriter, req *HTTPGatewayRequest, commands []string) error {
index, subindex, err := parseSdoCommand(commands[1:])
if err != nil {
log.Errorf("[HTTP][SERVER] unable to parse SDO command : %v", err)
return err
}
net := gw.base.network
buffer := gw.sdoBuffer

n, err := net.ReadRaw(uint8(req.nodeId), uint16(index), uint8(subindex), buffer)
if err != nil {
w.Write(NewResponseError(int(req.sequence), err))
return nil
}
sdoResp := httpSDOReadResponse{
Sequence: int(req.sequence),
Response: "OK",
Data: "0x" + hex.EncodeToString(buffer[:n]),
Length: n,
}
sdoResRaw, err := json.Marshal(sdoResp)
if err != nil {
return ErrGwRequestNotProcessed
}
w.Write(sdoResRaw)
return nil
}

// Handle a write
// This includes different type of handlers : SDO, PDO, ...
func (gw *HTTPGatewayServer) handleWrite(w doneWriter, req *HTTPGatewayRequest) error {
matchSDO := regSDO.FindStringSubmatch(req.command)
if len(matchSDO) >= 2 {
return gw.handlerSDOWrite(w, req, matchSDO)
}
matchPDO := regPDO.FindStringSubmatch(req.command)
if len(matchPDO) >= 2 {
return handlerNotSupported(w, req)
}
return ErrGwSyntaxError
}

func (gw *HTTPGatewayServer) handlerSDOWrite(w doneWriter, req *HTTPGatewayRequest, commands []string) error {
index, subindex, err := parseSdoCommand(commands[1:])
if err != nil {
log.Errorf("[HTTP][SERVER] unable to parse SDO command : %v", err)
return err
}
net := gw.base.network

var sdoWrite httpSDOWriteRequest
err = json.Unmarshal(req.parameters, &sdoWrite)
if err != nil {
return ErrGwSyntaxError
}
datatype, ok := HTTP_DATATYPE_MAP[sdoWrite.Datatype]
if !ok {
log.Errorf("[HTTP][SERVER] requested datatype is either wrong or unsupported : %v", sdoWrite.Datatype)
return ErrGwRequestNotSupported
}
encodedValue, err := encode(sdoWrite.Value, datatype, 0)
if err != nil {
return ErrGwSyntaxError
}
err = net.WriteRaw(uint8(req.nodeId), uint16(index), uint8(subindex), encodedValue)
if err != nil {
w.Write(NewResponseError(int(req.sequence), err))
return nil
}
return nil
}

// Update SDO client timeout
func (gw *HTTPGatewayServer) handleSDOTimeout(w doneWriter, req *HTTPGatewayRequest) error {

var sdoTimeout httpSDOTimeoutRequest
err := json.Unmarshal(req.parameters, &sdoTimeout)
if err != nil {
return ErrGwSyntaxError
}
sdoTimeoutInt, err := strconv.ParseUint(sdoTimeout.Value, 0, 64)
if err != nil || sdoTimeoutInt > 0xFFFF {
return ErrGwSyntaxError
}
return gw.base.SetSDOTimeout(uint32(sdoTimeoutInt))
}
123 changes: 123 additions & 0 deletions gateway_http_parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package canopen

import (
"encoding/json"
"io"
"net/http"
"strconv"

log "github.com/sirupsen/logrus"
)

const TOKEN_NONE = -3
const TOKEN_DEFAULT = -2
const TOKEN_ALL = -1

// Gets SDO command as list of strings and processes it
func parseSdoCommand(command []string) (index uint64, subindex uint64, err error) {
if len(command) != 3 {
return 0, 0, ErrGwSyntaxError
}
indexStr := command[1]
subIndexStr := command[2]
// Unclear if this is "supported" not really specified in 309-5
if indexStr == "all" {
return 0, 0, ErrGwRequestNotSupported
}
index, e := strconv.ParseUint(indexStr, 0, 64)
if e != nil {
return 0, 0, ErrGwSyntaxError
}
subIndex, e := strconv.ParseUint(subIndexStr, 0, 64)
if e != nil {
return 0, 0, ErrGwSyntaxError
}
if index > 0xFFFF || subindex > 0xFF {
return 0, 0, ErrGwSyntaxError
}
return index, subIndex, nil
}

// Parse raw network / node string param
func parseNodeOrNetworkParam(param string) (int, error) {
// Check if any of the string values
switch param {
case "default":
return TOKEN_DEFAULT, nil
case "none":
return TOKEN_NONE, nil
case "all":
return TOKEN_ALL, nil
}
// Else try a specific id
// This automatically treats 0x,0X,... correctly
// which is allowed in the spec
paramUint, err := strconv.ParseUint(param, 0, 64)
if err != nil {
return 0, err
}
return int(paramUint), nil
}

// Create a new sanitized api request object from raw http request
// This function also checks that values are within bounds etc.
func NewGatewayRequestFromRaw(r *http.Request) (*HTTPGatewayRequest, error) {
// Global expression match
match := regURI.FindStringSubmatch(r.URL.Path)
if len(match) != 6 {
log.Error("[HTTP][SERVER] request does not match a known API pattern")
return nil, ErrGwSyntaxError
}
// Check differents components of API route : api, sequence number, network and node
apiVersion := match[1]
if apiVersion != API_VERSION {
log.Errorf("[HTTP][SERVER] api version %v is not supported", apiVersion)
return nil, ErrGwRequestNotSupported
}
sequence, err := strconv.Atoi(match[2])
if err != nil || sequence > MAX_SEQUENCE_NB {
log.Errorf("[HTTP][SERVER] error processing sequence number %v", match[2])
return nil, ErrGwSyntaxError
}
netStr := match[3]
netInt, err := parseNodeOrNetworkParam(netStr)
if err != nil || netInt == 0 || netInt > 0xFFFF {
log.Errorf("[HTTP][SERVER] error processing network param %v", netStr)
return nil, ErrGwUnsupportedNet
}
nodeStr := match[4]
nodeInt, err := parseNodeOrNetworkParam(nodeStr)
if err != nil || nodeInt == 0 || nodeInt > 127 {
log.Errorf("[HTTP][SERVER] error processing node param %v", nodeStr)
}

// Unmarshall request body
var parameters json.RawMessage
err = json.NewDecoder(r.Body).Decode(&parameters)
if err != nil && err != io.EOF {
log.Warnf("[HTTP][SERVER] failed to unmarshal request body : %v", err)
return nil, ErrGwSyntaxError
}
request := &HTTPGatewayRequest{
nodeId: nodeInt,
networkId: netInt,
command: match[5], // Contains rest of URL after node
sequence: uint32(sequence),
parameters: parameters,
}
return request, nil
}

func NewResponseError(sequence int, error error) []byte {
gwErr, ok := error.(*HTTPGatewayError)
if !ok {
gwErr = ErrGwRequestNotProcessed // Apparently no "internal error"
}
jData, _ := json.Marshal(map[string]string{"sequence": strconv.Itoa(sequence), "response": gwErr.Error()})
return jData
}

func NewResponseSuccess(sequence int) []byte {
jData, _ := json.Marshal(map[string]string{"sequence": strconv.Itoa(sequence), "response": "OK"})
return jData
}
Loading

0 comments on commit 3e5f611

Please sign in to comment.