256 lines
7.6 KiB
Go
256 lines
7.6 KiB
Go
|
|
// Package manualsync synchronizes measurement manual-mode changes with the
|
||
|
|
// protocol service responsible for the measurement's data source
|
||
|
|
package manualsync
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"math"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
|
||
|
|
"modelRT/config"
|
||
|
|
"modelRT/constants"
|
||
|
|
"modelRT/orm"
|
||
|
|
)
|
||
|
|
|
||
|
|
const maxErrorResponseBody = 4 << 10
|
||
|
|
|
||
|
|
// SyntheticData is one manually supplied measurement value
|
||
|
|
type SyntheticData struct {
|
||
|
|
Time int64 `json:"time"`
|
||
|
|
Value float64 `json:"value"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Target identifies a measurement in a downstream protocol service
|
||
|
|
type Target struct {
|
||
|
|
Type int `json:"type"`
|
||
|
|
Station string `json:"station"`
|
||
|
|
MainPos string `json:"main_pos"`
|
||
|
|
SubPos string `json:"sub_pos"`
|
||
|
|
Option string `json:"option"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Request is the payload accepted by POST /api/manual
|
||
|
|
type Request struct {
|
||
|
|
Mode int16 `json:"mode"`
|
||
|
|
Data []SyntheticData `json:"data,omitempty"`
|
||
|
|
Target Target `json:"target"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Syncer synchronizes a measurement mode or manual-value change
|
||
|
|
type Syncer interface {
|
||
|
|
Sync(context.Context, orm.JSONMap, int16, *SyntheticData) error
|
||
|
|
}
|
||
|
|
|
||
|
|
// Client calls the protocol-specific manual synchronization endpoint
|
||
|
|
type Client struct {
|
||
|
|
httpClient *http.Client
|
||
|
|
protocolCL3611URL string
|
||
|
|
protocol104URL string
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewClient validates the configuration and constructs a reusable client
|
||
|
|
func NewClient(cfg config.ManualSyncConfig) (*Client, error) {
|
||
|
|
if cfg.Timeout <= 0 {
|
||
|
|
return nil, fmt.Errorf("manual sync timeout must be greater than zero")
|
||
|
|
}
|
||
|
|
protocolCL3611URL, err := endpointURL(cfg.ProtocolCL3611URL, cfg.APIPath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("invalid protocol CL3611 URL: %w", err)
|
||
|
|
}
|
||
|
|
protocol104URL, err := endpointURL(cfg.Protocol104URL, cfg.APIPath)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("invalid protocol 104 URL: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &Client{
|
||
|
|
httpClient: &http.Client{Timeout: cfg.Timeout},
|
||
|
|
protocolCL3611URL: protocolCL3611URL,
|
||
|
|
protocol104URL: protocol104URL,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sync posts one mode transition or manual-value update. Data is omitted for
|
||
|
|
// mode transitions and included only when sample is non-nil in manual mode
|
||
|
|
func (c *Client) Sync(ctx context.Context, dataSource orm.JSONMap, mode int16, data *SyntheticData) error {
|
||
|
|
if c == nil || c.httpClient == nil {
|
||
|
|
return fmt.Errorf("manual sync client is not initialized")
|
||
|
|
}
|
||
|
|
if mode != constants.MeasurementModeManual && mode != constants.MeasurementModeAutomatic {
|
||
|
|
return fmt.Errorf("manual sync mode must be 0 or 1, got %d", mode)
|
||
|
|
}
|
||
|
|
if mode == constants.MeasurementModeAutomatic && data != nil {
|
||
|
|
return fmt.Errorf("automatic mode manual sync request cannot contain data")
|
||
|
|
}
|
||
|
|
|
||
|
|
endpoint, target, err := c.resolveTarget(dataSource)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
requestPayload := Request{Mode: mode, Target: target}
|
||
|
|
if data != nil {
|
||
|
|
requestPayload.Data = []SyntheticData{*data}
|
||
|
|
}
|
||
|
|
body, err := json.Marshal(requestPayload)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("encode manual sync request: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("create manual sync request: %w", err)
|
||
|
|
}
|
||
|
|
request.Header.Set("Content-Type", "application/json")
|
||
|
|
|
||
|
|
response, err := c.httpClient.Do(request)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("call manual sync endpoint: %w", err)
|
||
|
|
}
|
||
|
|
defer response.Body.Close()
|
||
|
|
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
|
||
|
|
_, _ = io.Copy(io.Discard, response.Body)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, maxErrorResponseBody))
|
||
|
|
if readErr != nil {
|
||
|
|
return fmt.Errorf("manual sync endpoint returned %s and response body could not be read: %w", response.Status, readErr)
|
||
|
|
}
|
||
|
|
message := strings.TrimSpace(string(responseBody))
|
||
|
|
if message == "" {
|
||
|
|
return fmt.Errorf("manual sync endpoint returned %s", response.Status)
|
||
|
|
}
|
||
|
|
return fmt.Errorf("manual sync endpoint returned %s: %s", response.Status, message)
|
||
|
|
}
|
||
|
|
|
||
|
|
type rawDataSource struct {
|
||
|
|
Type int `json:"type"`
|
||
|
|
IOAddress rawIOAddress `json:"io_address"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type rawIOAddress struct {
|
||
|
|
DType int `json:"dtype"`
|
||
|
|
Station string `json:"station"`
|
||
|
|
Device string `json:"device"`
|
||
|
|
Channel string `json:"channel"`
|
||
|
|
Option string `json:"option"`
|
||
|
|
Packet any `json:"packet"`
|
||
|
|
Offset any `json:"offset"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) resolveTarget(dataSource orm.JSONMap) (string, Target, error) {
|
||
|
|
if dataSource == nil {
|
||
|
|
return "", Target{}, fmt.Errorf("measurement data_source is null")
|
||
|
|
}
|
||
|
|
encoded, err := json.Marshal(dataSource)
|
||
|
|
if err != nil {
|
||
|
|
return "", Target{}, fmt.Errorf("encode measurement data_source: %w", err)
|
||
|
|
}
|
||
|
|
var source rawDataSource
|
||
|
|
if err := json.Unmarshal(encoded, &source); err != nil {
|
||
|
|
return "", Target{}, fmt.Errorf("decode measurement data_source: %w", err)
|
||
|
|
}
|
||
|
|
station := strings.TrimSpace(source.IOAddress.Station)
|
||
|
|
if station == "" {
|
||
|
|
return "", Target{}, fmt.Errorf("measurement data_source io_address.station is required")
|
||
|
|
}
|
||
|
|
|
||
|
|
switch source.Type {
|
||
|
|
case 1:
|
||
|
|
device := strings.TrimSpace(source.IOAddress.Device)
|
||
|
|
channel := strings.TrimSpace(source.IOAddress.Channel)
|
||
|
|
if device == "" {
|
||
|
|
return "", Target{}, fmt.Errorf("CL3611 data_source io_address.device is required")
|
||
|
|
}
|
||
|
|
if channel == "" {
|
||
|
|
return "", Target{}, fmt.Errorf("CL3611 data_source io_address.channel is required")
|
||
|
|
}
|
||
|
|
target := Target{
|
||
|
|
Station: station,
|
||
|
|
MainPos: device,
|
||
|
|
SubPos: channel,
|
||
|
|
}
|
||
|
|
switch source.IOAddress.DType {
|
||
|
|
case 1:
|
||
|
|
target.Type = 1
|
||
|
|
target.Option = strings.TrimSpace(source.IOAddress.Option)
|
||
|
|
case 2:
|
||
|
|
target.Type = 2
|
||
|
|
default:
|
||
|
|
return "", Target{}, fmt.Errorf("CL3611 data_source dtype must be 1 or 2, got %d", source.IOAddress.DType)
|
||
|
|
}
|
||
|
|
return c.protocolCL3611URL, target, nil
|
||
|
|
case 2:
|
||
|
|
packet, err := integerString(source.IOAddress.Packet)
|
||
|
|
if err != nil {
|
||
|
|
return "", Target{}, fmt.Errorf("104 data_source io_address.packet: %w", err)
|
||
|
|
}
|
||
|
|
offset, err := integerString(source.IOAddress.Offset)
|
||
|
|
if err != nil {
|
||
|
|
return "", Target{}, fmt.Errorf("104 data_source io_address.offset: %w", err)
|
||
|
|
}
|
||
|
|
return c.protocol104URL, Target{
|
||
|
|
Type: 3,
|
||
|
|
Station: station,
|
||
|
|
MainPos: packet,
|
||
|
|
SubPos: offset,
|
||
|
|
Option: "",
|
||
|
|
}, nil
|
||
|
|
default:
|
||
|
|
return "", Target{}, fmt.Errorf("unsupported measurement data_source type %d", source.Type)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func endpointURL(baseURL, apiPath string) (string, error) {
|
||
|
|
baseURL = strings.TrimSpace(baseURL)
|
||
|
|
if baseURL == "" {
|
||
|
|
return "", fmt.Errorf("base URL is required")
|
||
|
|
}
|
||
|
|
apiPath = strings.TrimSpace(apiPath)
|
||
|
|
if apiPath == "" {
|
||
|
|
return "", fmt.Errorf("API path is required")
|
||
|
|
}
|
||
|
|
endpoint := strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(apiPath, "/")
|
||
|
|
parsed, err := url.Parse(endpoint)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||
|
|
return "", fmt.Errorf("URL scheme must be http or https")
|
||
|
|
}
|
||
|
|
if parsed.Host == "" {
|
||
|
|
return "", fmt.Errorf("URL host is required")
|
||
|
|
}
|
||
|
|
return parsed.String(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func integerString(value any) (string, error) {
|
||
|
|
switch typed := value.(type) {
|
||
|
|
case nil:
|
||
|
|
return "", fmt.Errorf("is required")
|
||
|
|
case float64:
|
||
|
|
if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed {
|
||
|
|
return "", fmt.Errorf("must be an integer")
|
||
|
|
}
|
||
|
|
return strconv.FormatInt(int64(typed), 10), nil
|
||
|
|
case string:
|
||
|
|
trimmed := strings.TrimSpace(typed)
|
||
|
|
if trimmed == "" {
|
||
|
|
return "", fmt.Errorf("is required")
|
||
|
|
}
|
||
|
|
integer, err := strconv.ParseInt(trimmed, 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("must be an integer: %w", err)
|
||
|
|
}
|
||
|
|
return strconv.FormatInt(integer, 10), nil
|
||
|
|
default:
|
||
|
|
return "", fmt.Errorf("has unsupported type %T", value)
|
||
|
|
}
|
||
|
|
}
|