AnomalyDetector initialized in main() with periodic model updates. Anomaly events broadcast to dashboard WS as 'alert' messages via BroadcastAlert. GET /api/anomalies?since=24h lists recent events. POST /api/security/arm and /api/security/disarm manage security mode. GET /api/security/status returns armed state, learning progress, and 24h anomaly count. Arm/disarm state persisted to learning_state table and restored on restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
627 lines
18 KiB
Go
627 lines
18 KiB
Go
// Package api provides REST API handlers for Spaxel automation triggers.
|
|
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// TriggersHandler manages automation triggers.
|
|
type TriggersHandler struct {
|
|
mu sync.RWMutex
|
|
db *sql.DB
|
|
triggers map[string]*Trigger
|
|
engine TriggerEngine
|
|
}
|
|
|
|
// Trigger represents an automation trigger.
|
|
type Trigger struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
Condition string `json:"condition"` // enter, leave, dwell, vacant, count
|
|
ConditionParams json.RawMessage `json:"condition_params"`
|
|
TimeConstraint json.RawMessage `json:"time_constraint,omitempty"`
|
|
Actions json.RawMessage `json:"actions"`
|
|
LastFired *time.Time `json:"last_fired,omitempty"`
|
|
Elapsed int `json:"elapsed,omitempty"` // seconds since last fire
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// TriggerEngine is the interface to the automation engine.
|
|
type TriggerEngine interface {
|
|
TestFire(triggerID string) error
|
|
IsInVolume(x, y, z float64, volumeID string) bool
|
|
}
|
|
|
|
// NewTriggersHandler creates a new triggers handler.
|
|
func NewTriggersHandler(dbPath string) (*TriggersHandler, error) {
|
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
db, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(1)
|
|
|
|
t := &TriggersHandler{
|
|
db: db,
|
|
triggers: make(map[string]*Trigger),
|
|
}
|
|
|
|
if err := t.migrate(); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
|
|
if err := t.load(); err != nil {
|
|
log.Printf("[WARN] Failed to load triggers: %v", err)
|
|
}
|
|
|
|
return t, nil
|
|
}
|
|
|
|
func (t *TriggersHandler) migrate() error {
|
|
_, err := t.db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS triggers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
condition TEXT NOT NULL,
|
|
condition_params TEXT NOT NULL DEFAULT '{}',
|
|
time_constraint TEXT NOT NULL DEFAULT '{}',
|
|
actions TEXT NOT NULL DEFAULT '[]',
|
|
last_fired INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func (t *TriggersHandler) load() error {
|
|
rows, err := t.db.Query(`
|
|
SELECT id, name, enabled, condition, condition_params, time_constraint, actions, last_fired, created_at
|
|
FROM triggers
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var trigger Trigger
|
|
var enabled int
|
|
var lastFiredNS int64
|
|
var createdAtNS int64
|
|
|
|
if err := rows.Scan(&trigger.ID, &trigger.Name, &enabled, &trigger.Condition,
|
|
&trigger.ConditionParams, &trigger.TimeConstraint, &trigger.Actions,
|
|
&lastFiredNS, &createdAtNS); err != nil {
|
|
continue
|
|
}
|
|
|
|
trigger.Enabled = enabled != 0
|
|
if lastFiredNS > 0 {
|
|
ts := time.Unix(0, lastFiredNS)
|
|
trigger.LastFired = &ts
|
|
trigger.Elapsed = int(time.Since(ts).Seconds())
|
|
}
|
|
trigger.CreatedAt = time.Unix(0, createdAtNS)
|
|
|
|
t.triggers[trigger.ID] = &trigger
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Close closes the database.
|
|
func (t *TriggersHandler) Close() error {
|
|
return t.db.Close()
|
|
}
|
|
|
|
// SetEngine sets the automation engine for testing.
|
|
func (t *TriggersHandler) SetEngine(engine TriggerEngine) {
|
|
t.mu.Lock()
|
|
t.engine = engine
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
// RegisterRoutes registers triggers endpoints on the given router.
|
|
//
|
|
// GET /api/triggers
|
|
//
|
|
// @Summary List all triggers
|
|
// @Description Returns all registered automation triggers as a JSON array. Each trigger includes its condition, actions, enabled state, and elapsed time since last fire.
|
|
// @Tags triggers
|
|
// @Produce json
|
|
// @Success 200 {array} Trigger "List of triggers"
|
|
// @Router /api/triggers [get]
|
|
//
|
|
// POST /api/triggers
|
|
//
|
|
// @Summary Create a trigger
|
|
// @Description Creates a new automation trigger. The request body must include id, name, and condition. Actions default to an empty array if omitted.
|
|
// @Tags triggers
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param trigger body createTriggerRequest true "Trigger definition"
|
|
// @Success 201 {object} Trigger "Created trigger"
|
|
// @Failure 400 {object} map[string]string "Missing required fields or invalid condition value"
|
|
// @Failure 500 {object} map[string]string "Database error"
|
|
// @Router /api/triggers [post]
|
|
//
|
|
// PUT /api/triggers/{id}
|
|
//
|
|
// @Summary Update a trigger
|
|
// @Description Updates an existing trigger. Only fields present in the request body are modified; omitted fields retain their current values.
|
|
// @Tags triggers
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Trigger ID"
|
|
// @Param trigger body updateTriggerRequest true "Partial trigger object with fields to update"
|
|
// @Success 200 {object} Trigger "Updated trigger"
|
|
// @Failure 400 {object} map[string]string "Invalid request body or invalid condition value"
|
|
// @Failure 404 {object} map[string]string "Trigger not found"
|
|
// @Failure 500 {object} map[string]string "Database error"
|
|
// @Router /api/triggers/{id} [put]
|
|
//
|
|
// DELETE /api/triggers/{id}
|
|
//
|
|
// @Summary Delete a trigger
|
|
// @Description Removes a trigger by ID. Deleting a nonexistent ID returns 404.
|
|
// @Tags triggers
|
|
// @Param id path string true "Trigger ID"
|
|
// @Success 204 "Trigger deleted"
|
|
// @Failure 404 {object} map[string]string "Trigger not found"
|
|
// @Failure 500 {object} map[string]string "Database error"
|
|
// @Router /api/triggers/{id} [delete]
|
|
//
|
|
// POST /api/triggers/{id}/test
|
|
//
|
|
// @Summary Test-fire a trigger
|
|
// @Description Fires the trigger's actions once with a synthetic event payload for testing. If no automation engine is attached, returns a simulated success response. Does not update last_fired or trigger any real automation logic.
|
|
// @Tags triggers
|
|
// @Produce json
|
|
// @Param id path string true "Trigger ID"
|
|
// @Success 200 {object} map[string]interface{} "Test fire result with status and trigger details"
|
|
// @Failure 404 {object} map[string]string "Trigger not found"
|
|
// @Failure 500 {object} map[string]string "Engine test-fire failed"
|
|
// @Router /api/triggers/{id}/test [post]
|
|
func (t *TriggersHandler) RegisterRoutes(r chi.Router) {
|
|
r.Get("/api/triggers", t.listTriggers)
|
|
r.Post("/api/triggers", t.createTrigger)
|
|
r.Put("/api/triggers/{id}", t.updateTrigger)
|
|
r.Delete("/api/triggers/{id}", t.deleteTrigger)
|
|
r.Post("/api/triggers/{id}/test", t.testTrigger)
|
|
}
|
|
|
|
// listTriggers handles GET /api/triggers.
|
|
//
|
|
// Returns all registered triggers as a JSON array.
|
|
//
|
|
// Response 200 (application/json):
|
|
//
|
|
// [{
|
|
// "id": "t1",
|
|
// "name": "Couch Dwell",
|
|
// "enabled": true,
|
|
// "condition": "dwell",
|
|
// "condition_params": {"duration_s": 30},
|
|
// "time_constraint": {"from": "22:00", "to": "06:00"},
|
|
// "actions": [{"type": "webhook", "url": "http://example.com/hook"}],
|
|
// "last_fired": "2024-03-15T14:32:05Z",
|
|
// "elapsed": 142,
|
|
// "created_at": "2024-03-10T08:00:00Z"
|
|
// }]
|
|
func (t *TriggersHandler) listTriggers(w http.ResponseWriter, r *http.Request) {
|
|
t.mu.RLock()
|
|
triggers := make([]*Trigger, 0, len(t.triggers))
|
|
for _, trigger := range t.triggers {
|
|
if trigger.LastFired != nil {
|
|
trigger.Elapsed = int(time.Since(*trigger.LastFired).Seconds())
|
|
}
|
|
triggers = append(triggers, trigger)
|
|
}
|
|
t.mu.RUnlock()
|
|
|
|
writeJSON(w, http.StatusOK, triggers)
|
|
}
|
|
|
|
type createTriggerRequest struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
Condition string `json:"condition"`
|
|
ConditionParams json.RawMessage `json:"condition_params"`
|
|
TimeConstraint json.RawMessage `json:"time_constraint,omitempty"`
|
|
Actions json.RawMessage `json:"actions"`
|
|
}
|
|
|
|
// createTrigger handles POST /api/triggers.
|
|
//
|
|
// Creates a new automation trigger. The request body must include id, name,
|
|
// and condition. Actions default to an empty array if omitted.
|
|
//
|
|
// Request body (application/json):
|
|
//
|
|
// {
|
|
// "id": "t1",
|
|
// "name": "Couch Dwell",
|
|
// "condition": "dwell",
|
|
// "condition_params": {"duration_s": 30},
|
|
// "time_constraint": {"from": "22:00", "to": "06:00"},
|
|
// "actions": [{"type": "webhook", "url": "http://example.com/hook"}],
|
|
// "enabled": true
|
|
// }
|
|
//
|
|
// Response 201 (application/json): the created trigger object.
|
|
// Response 400: missing required fields or invalid condition value.
|
|
// Response 500: database error.
|
|
func (t *TriggersHandler) createTrigger(w http.ResponseWriter, r *http.Request) {
|
|
var req createTriggerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if req.ID == "" {
|
|
http.Error(w, "id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if req.Name == "" {
|
|
http.Error(w, "name is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
validConditions := map[string]bool{
|
|
"enter": true, "leave": true, "dwell": true,
|
|
"vacant": true, "count": true,
|
|
}
|
|
if !validConditions[req.Condition] {
|
|
http.Error(w, "condition must be one of: enter, leave, dwell, vacant, count", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
now := time.Now().UnixNano()
|
|
enabled := 1
|
|
if req.Enabled != nil && !*req.Enabled {
|
|
enabled = 0
|
|
}
|
|
|
|
conditionParams := req.ConditionParams
|
|
if len(conditionParams) == 0 {
|
|
conditionParams = []byte("{}")
|
|
}
|
|
timeConstraint := req.TimeConstraint
|
|
if len(timeConstraint) == 0 {
|
|
timeConstraint = []byte("{}")
|
|
}
|
|
actions := req.Actions
|
|
if len(actions) == 0 {
|
|
actions = []byte("[]")
|
|
}
|
|
|
|
_, err := t.db.Exec(`
|
|
INSERT INTO triggers (id, name, enabled, condition, condition_params, time_constraint, actions, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, req.ID, req.Name, enabled, req.Condition, string(conditionParams),
|
|
string(timeConstraint), string(actions), now)
|
|
if err != nil {
|
|
http.Error(w, "failed to create trigger", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
t.mu.Lock()
|
|
t.triggers[req.ID] = &Trigger{
|
|
ID: req.ID,
|
|
Name: req.Name,
|
|
Enabled: enabled != 0,
|
|
Condition: req.Condition,
|
|
ConditionParams: conditionParams,
|
|
TimeConstraint: timeConstraint,
|
|
Actions: actions,
|
|
CreatedAt: time.Unix(0, now),
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
writeJSON(w, http.StatusCreated, t.triggers[req.ID])
|
|
}
|
|
|
|
type updateTriggerRequest struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
Condition *string `json:"condition,omitempty"`
|
|
ConditionParams *json.RawMessage `json:"condition_params,omitempty"`
|
|
TimeConstraint *json.RawMessage `json:"time_constraint,omitempty"`
|
|
Actions *json.RawMessage `json:"actions,omitempty"`
|
|
}
|
|
|
|
// updateTrigger handles PUT /api/triggers/{id}.
|
|
//
|
|
// Updates an existing trigger. Only fields present in the request body are
|
|
// modified; omitted fields retain their current values. If the body contains
|
|
// no recognized fields, the current trigger is returned unchanged.
|
|
//
|
|
// Request body (application/json): partial trigger object with fields to update.
|
|
//
|
|
// Response 200 (application/json): the updated trigger object.
|
|
// Response 400: invalid request body or invalid condition value.
|
|
// Response 404: trigger not found.
|
|
// Response 500: database error.
|
|
func (t *TriggersHandler) updateTrigger(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
t.mu.RLock()
|
|
trigger, exists := t.triggers[id]
|
|
t.mu.RUnlock()
|
|
|
|
if !exists {
|
|
http.Error(w, "trigger not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var req updateTriggerRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
updates := []string{}
|
|
args := []interface{}{}
|
|
|
|
if req.Name != nil {
|
|
updates = append(updates, "name = ?")
|
|
args = append(args, *req.Name)
|
|
}
|
|
if req.Enabled != nil {
|
|
updates = append(updates, "enabled = ?")
|
|
if *req.Enabled {
|
|
args = append(args, 1)
|
|
} else {
|
|
args = append(args, 0)
|
|
}
|
|
}
|
|
if req.Condition != nil {
|
|
validConditions := map[string]bool{
|
|
"enter": true, "leave": true, "dwell": true,
|
|
"vacant": true, "count": true,
|
|
}
|
|
if !validConditions[*req.Condition] {
|
|
http.Error(w, "condition must be one of: enter, leave, dwell, vacant, count", http.StatusBadRequest)
|
|
return
|
|
}
|
|
updates = append(updates, "condition = ?")
|
|
args = append(args, *req.Condition)
|
|
}
|
|
if req.ConditionParams != nil {
|
|
updates = append(updates, "condition_params = ?")
|
|
args = append(args, string(*req.ConditionParams))
|
|
}
|
|
if req.TimeConstraint != nil {
|
|
updates = append(updates, "time_constraint = ?")
|
|
args = append(args, string(*req.TimeConstraint))
|
|
}
|
|
if req.Actions != nil {
|
|
updates = append(updates, "actions = ?")
|
|
args = append(args, string(*req.Actions))
|
|
}
|
|
|
|
if len(updates) == 0 {
|
|
writeJSON(w, http.StatusOK, trigger)
|
|
return
|
|
}
|
|
|
|
args = append(args, id)
|
|
query := "UPDATE triggers SET " + strings.Join(updates, ", ") + " WHERE id = ?"
|
|
|
|
_, err := t.db.Exec(query, args...)
|
|
if err != nil {
|
|
http.Error(w, "failed to update trigger", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Update in-memory copy
|
|
t.mu.Lock()
|
|
if req.Name != nil {
|
|
trigger.Name = *req.Name
|
|
}
|
|
if req.Enabled != nil {
|
|
trigger.Enabled = *req.Enabled
|
|
}
|
|
if req.Condition != nil {
|
|
trigger.Condition = *req.Condition
|
|
}
|
|
if req.ConditionParams != nil {
|
|
trigger.ConditionParams = *req.ConditionParams
|
|
}
|
|
if req.TimeConstraint != nil {
|
|
trigger.TimeConstraint = *req.TimeConstraint
|
|
}
|
|
if req.Actions != nil {
|
|
trigger.Actions = *req.Actions
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
writeJSON(w, http.StatusOK, trigger)
|
|
}
|
|
|
|
// deleteTrigger handles DELETE /api/triggers/{id}.
|
|
//
|
|
// Removes a trigger by ID. Deleting a nonexistent ID is a no-op.
|
|
//
|
|
// Response 204: trigger deleted (or did not exist).
|
|
// Response 500: database error.
|
|
func (t *TriggersHandler) deleteTrigger(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
t.mu.RLock()
|
|
_, exists := t.triggers[id]
|
|
t.mu.RUnlock()
|
|
|
|
if !exists {
|
|
http.Error(w, "trigger not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
_, err := t.db.Exec(`DELETE FROM triggers WHERE id = ?`, id)
|
|
if err != nil {
|
|
http.Error(w, "failed to delete trigger", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
t.mu.Lock()
|
|
delete(t.triggers, id)
|
|
t.mu.Unlock()
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// testTrigger handles POST /api/triggers/{id}/test.
|
|
//
|
|
// Fires the trigger's actions once with a synthetic event payload for testing.
|
|
// If no automation engine is attached, returns a simulated success response.
|
|
// Does not update last_fired or trigger any real automation logic.
|
|
//
|
|
// Response 200 (application/json):
|
|
//
|
|
// {
|
|
// "status": "fired",
|
|
// "message": "Trigger fired successfully",
|
|
// "trigger": { ... }
|
|
// }
|
|
//
|
|
// Response 404: trigger not found.
|
|
// Response 500: engine test-fire failed.
|
|
func (t *TriggersHandler) testTrigger(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
t.mu.RLock()
|
|
trigger, exists := t.triggers[id]
|
|
t.mu.RUnlock()
|
|
|
|
if !exists {
|
|
http.Error(w, "trigger not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
t.mu.RLock()
|
|
engine := t.engine
|
|
t.mu.RUnlock()
|
|
|
|
if engine == nil {
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "ok",
|
|
"message": "trigger test simulated (no engine attached)",
|
|
"trigger": trigger,
|
|
})
|
|
return
|
|
}
|
|
|
|
if err := engine.TestFire(id); err != nil {
|
|
http.Error(w, fmt.Sprintf("test fire failed: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"status": "fired",
|
|
"message": "Trigger fired successfully",
|
|
"trigger": trigger,
|
|
})
|
|
}
|
|
|
|
// ── Trigger evaluation (called by fusion engine) ───────────────────────────────────
|
|
|
|
// EvaluateTriggers evaluates all enabled triggers against current state.
|
|
// Returns a list of trigger IDs that should fire.
|
|
func (t *TriggersHandler) EvaluateTriggers(blobs []BlobPos) []string {
|
|
t.mu.RLock()
|
|
defer t.mu.RUnlock()
|
|
|
|
var fired []string
|
|
|
|
for _, trigger := range t.triggers {
|
|
if !trigger.Enabled {
|
|
continue
|
|
}
|
|
|
|
// Check cooldown (5 second minimum refire interval)
|
|
if trigger.LastFired != nil && time.Since(*trigger.LastFired) < 5*time.Second {
|
|
continue
|
|
}
|
|
|
|
// Parse condition params
|
|
var params struct {
|
|
DurationS *int `json:"duration_s"`
|
|
CountThreshold *int `json:"count_threshold"`
|
|
PersonID string `json:"person_id,omitempty"`
|
|
VolumeID string `json:"volume_id,omitempty"`
|
|
}
|
|
if len(trigger.ConditionParams) > 0 && string(trigger.ConditionParams) != "{}" {
|
|
json.Unmarshal(trigger.ConditionParams, ¶ms)
|
|
}
|
|
|
|
shouldFire := false
|
|
switch trigger.Condition {
|
|
case "enter", "leave":
|
|
if params.VolumeID != "" {
|
|
for _, blob := range blobs {
|
|
if t.engine != nil && t.engine.IsInVolume(blob.X, blob.Y, blob.Z, params.VolumeID) {
|
|
if trigger.Condition == "enter" {
|
|
shouldFire = true
|
|
}
|
|
} else {
|
|
if trigger.Condition == "leave" {
|
|
shouldFire = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case "dwell":
|
|
if params.DurationS != nil && trigger.LastFired != nil {
|
|
elapsed := int(time.Since(*trigger.LastFired).Seconds())
|
|
if elapsed >= *params.DurationS {
|
|
shouldFire = true
|
|
}
|
|
}
|
|
case "vacant":
|
|
if len(blobs) == 0 {
|
|
shouldFire = true
|
|
}
|
|
case "count":
|
|
if params.CountThreshold != nil {
|
|
if len(blobs) >= *params.CountThreshold {
|
|
shouldFire = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if shouldFire {
|
|
fired = append(fired, trigger.ID)
|
|
now := time.Now()
|
|
trigger.LastFired = &now
|
|
trigger.Elapsed = 0
|
|
t.db.Exec(`UPDATE triggers SET last_fired = ? WHERE id = ?`, now.UnixNano(), trigger.ID)
|
|
}
|
|
}
|
|
|
|
return fired
|
|
}
|
|
|
|
// BlobPos represents a blob position for trigger evaluation.
|
|
type BlobPos struct {
|
|
ID int
|
|
X, Y, Z float64
|
|
}
|