modelRT/client/manualsync/client.go

256 lines
7.6 KiB
Go
Raw Normal View History

// 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)
}
}