diff --git a/.gitignore b/.gitignore
index df83fbc..8ca2c9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
*_amac
*_ilin
*.sh
+*.html
Makefile
# Test binary, built with `go test -c`
@@ -24,7 +25,7 @@ vendor/
# mbaigo
*.json
-go.mod
-go.sum
serviceRegistry.db
*.pem
+**/files/
+
diff --git a/README.md b/README.md
index 0432dfe..4d5966d 100644
--- a/README.md
+++ b/README.md
@@ -27,12 +27,15 @@ Many of the testing is done with the Raspberry Pi (3, 4, &5) with [GPIO](https:/
- 20103 Orchestrator
- 20104 Authorizer
- 20105 Modeler (local cloud semantics with GraphDB)
+- 20106 Messenger
- 20150 ds18b20 (1-wire sensor)
- 20151 Parallax (PWM)
- 20152 Thermostat
+- 20153 Revolutionary (Rev Pi PLC)
+- 20154 Levler (Level control)
- 20160 Picam
- 20161 USB microphone
- 20170 UA client (OPC UA)
- 20171 Modboss (Modbus TCP)
- 20172 Telegrapher (MQTT)
-- 20180 Influxer (Influx DB)
\ No newline at end of file
+- 20180 Influxer (Influx DB)
diff --git a/Influxer/README.md b/collector/README.md
similarity index 72%
rename from Influxer/README.md
rename to collector/README.md
index e5003a6..e93406c 100644
--- a/Influxer/README.md
+++ b/collector/README.md
@@ -1,6 +1,6 @@
-# mbaigo System: influxer
+# mbaigo System: Collector
-The Influxer is a system that as for asset the time series database [InfluxDB](https://en.wikipedia.org/wiki/InfluxDB).
+The Collector is a system that as for asset the time series database [InfluxDB](https://en.wikipedia.org/wiki/InfluxDB).
It offers one services, *squery*. squery provides a list of signals present in its bucket’s measurements.
@@ -12,33 +12,32 @@ As with the other systems, this is a prototype that shows that the mbaigo librar
## Compiling
To compile the code, one needs to get the AiGo module
```go get github.com/vanDeventer/mbaigo```
-and initialize the *go.mod* file with ``` go mod init github.com/vanDeventer/arrowsys/inflxer``` before running *go mod tidy*.
+and initialize the *go.mod* file with ``` go mod init github.com/vanDeventer/arrowsys/collector``` before running *go mod tidy*.
The reason the *go.mod* file is not included in the repository is that when developing the mbaigo module, a replace statement needs to be included to point to the development code.
-To run the code, one just needs to type in ```go run influxer.go thing.go``` within a terminal or at a command prompt.
+To run the code, one just needs to type in ```go run Collector.go thing.go``` within a terminal or at a command prompt.
It is **important** to start the program from within its own directory (and each system should have their own directory) because it looks for its configuration file there. If it does not find it there, it will generate one and shutdown to allow the configuration file to be updated.
The configuration and operation of the system can be verified using the system's web server using a standard web browser, whose address is provided by the system at startup.
To build the software for one's own machine,
-```go build -o influxer```.
+```go build -o Collector```.
## Cross compiling/building
The following commands enable one to build for different platforms:
-- Intel Mac: ```GOOS=darwin GOARCH=amd64 go build -o influxer_imac influxer.go thing.go```
-- ARM Mac: ```GOOS=darwin GOARCH=arm64 go build -o influxer_amac influxer.go thing.go```
-- Windows 64: ```GOOS=windows GOARCH=amd64 go build -o influxer.exe influxer.go thing.go```
-- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o influxer_rpi64 influxer.go thing.go```
-- (new) Raspberry Pi 32: ```GOOS=linux GOARCH=arm GOARM=7 go build -o influxer_rpi32 influxer.go thing.go```
-- Linux: ```GOOS=linux GOARCH=amd64 go build -o influxer_linux influxer.go thing.go```
+- Intel Mac: ```GOOS=darwin GOARCH=amd64 go build -o Collector_imac```
+- ARM Mac: ```GOOS=darwin GOARCH=arm64 go build -o Collector_amac ```
+- Windows 64: ```GOOS=windows GOARCH=amd64 go build -o Collector.exe```
+- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o Collector_rpi64```
+- Linux: ```GOOS=linux GOARCH=amd64 go build -o Collector_linux```
One can find a complete list of platform by typing *go tool dist list* at the command prompt
If one wants to secure copy it to a Raspberry pi,
-`scp influxer_rpi64 jan@192.168.1.6:Desktop/influxer/` where user is the *username* @ the *IP address* of the Raspberry Pi with a relative (to the user's home directory) target *Desktop/influxer/* directory.influxer
+`scp Collector_rpi64 jan@192.168.1.10:rpiExec/Collector/` where user is the *username* @ the *IP address* of the Raspberry Pi with a relative (to the user's home directory) target *rpiExec/Collector/* directory.Collector
## Deployment of the asset
diff --git a/Influxer/influxer.go b/collector/collector.go
similarity index 87%
rename from Influxer/influxer.go
rename to collector/collector.go
index 1800a8a..aa67f06 100644
--- a/Influxer/influxer.go
+++ b/collector/collector.go
@@ -18,6 +18,7 @@ package main
import (
"context"
+ "crypto/x509/pkix"
"encoding/json"
"fmt"
"log"
@@ -34,14 +35,22 @@ func main() {
defer cancel() // make sure all paths cancel the context to avoid context leak
// instantiate the System
- sys := components.NewSystem("influxer", ctx)
+ sys := components.NewSystem("Collector", ctx)
- // Instatiate the Capusle
+ // Instantiate the husk
sys.Husk = &components.Husk{
Description: " is a system that ingests time signals into an Influx database",
Details: map[string][]string{"Developer": {"Synecdoque"}},
ProtoPort: map[string]int{"https": 0, "http": 20180, "coap": 0},
InfoLink: "https://github.com/sdoque/systems/tree/main/influxer",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
}
// instantiate a template unit asset
@@ -50,17 +59,17 @@ func main() {
sys.UAssets[assetName] = &assetTemplate
// Configure the system
- rawResources, servsTemp, err := usecases.Configure(&sys)
+ rawResources, err := usecases.Configure(&sys)
if err != nil {
log.Fatalf("Configuration error: %v\n", err)
}
sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
for _, raw := range rawResources {
- var uac UnitAsset
+ var uac usecases.ConfigurableAsset
if err := json.Unmarshal(raw, &uac); err != nil {
log.Fatalf("Resource configuration error: %+v\n", err)
}
- ua, cleanup := newResource(uac, &sys, servsTemp)
+ ua, cleanup := newResource(uac, &sys)
defer cleanup()
sys.UAssets[ua.GetName()] = &ua
}
diff --git a/Influxer/thing.go b/collector/thing.go
similarity index 78%
rename from Influxer/thing.go
rename to collector/thing.go
index 0efe769..73b7352 100644
--- a/Influxer/thing.go
+++ b/collector/thing.go
@@ -18,6 +18,7 @@ package main
import (
"context"
+ "encoding/json"
"fmt"
"log"
"net/http"
@@ -43,6 +44,15 @@ type MeasurementT struct {
//-------------------------------------Define the unit asset
+// Traits are Asset-specific configurable parameters
+type Traits struct {
+ FluxURL string `json:"db_url"`
+ Token string `json:"token"`
+ Org string `json:"organization"`
+ Bucket string `json:"bucket"`
+ Measurements []MeasurementT `json:"measurements"`
+}
+
// UnitAsset type models the unit asset (interface) of the system
type UnitAsset struct {
Name string `json:"bucket_name"`
@@ -51,12 +61,8 @@ type UnitAsset struct {
ServicesMap components.Services `json:"-"`
CervicesMap components.Cervices `json:"-"`
//
- FluxURL string `json:"db_url"`
- Token string `json:"token"`
- Org string `json:"organization"`
- Bucket string `json:"bucket"`
- Measurements []MeasurementT `json:"measurements"`
- client influxdb2.Client // InfluxDB client
+ Traits
+ client influxdb2.Client // InfluxDB client
}
// GetName returns the name of the Resource.
@@ -79,6 +85,11 @@ func (ua *UnitAsset) GetDetails() map[string][]string {
return ua.Details
}
+// GetTraits returns the traits of the Resource.
+func (ua *UnitAsset) GetTraits() any {
+ return ua.Traits
+}
+
// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation)
var _ components.UnitAsset = (*UnitAsset)(nil)
@@ -98,15 +109,17 @@ func initTemplate() components.UnitAsset {
uat := &UnitAsset{
Name: "demo",
Details: map[string][]string{"Database": {"InfluxDB"}},
- FluxURL: "http://10.0.0.33:8086",
- Token: "K1NTWNlToyUNXdii7IwNJ1W-kMsagUr8w1r4cRVYqK-N-R9vVT1MCJwHFBxOgiW85iKiMSsUpbrxQsQZJA8IzA==",
- Org: "mbaigo",
- Bucket: "demo",
- Measurements: []MeasurementT{
- {
- Name: "temperature",
- Details: map[string][]string{"Location": {"Kitchen"}},
- Period: 3,
+ Traits: Traits{
+ FluxURL: "http://10.0.0.33:8086",
+ Token: "K1NTWNlToyUNXdii7IwNJ1W-kMsagUr8w1r4cRVYqK-N-R9vVT1MCJwHFBxOgiW85iKiMSsUpbrxQsQZJA8IzA==",
+ Org: "mbaigo",
+ Bucket: "demo",
+ Measurements: []MeasurementT{
+ {
+ Name: "temperature",
+ Details: map[string][]string{"Location": {"Kitchen"}},
+ Period: 3,
+ },
},
},
ServicesMap: components.Services{
@@ -119,19 +132,26 @@ func initTemplate() components.UnitAsset {
//-------------------------------------Instantiate the unit assets based on configuration
// newResource creates a new UnitAsset resource based on the configuration
-func newResource(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) {
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
ua := &UnitAsset{
- Name: uac.Name,
+ Name: configuredAsset.Name,
Owner: sys,
- Details: uac.Details,
- ServicesMap: components.CloneServices(servs),
- FluxURL: uac.FluxURL,
- Token: uac.Token,
- Org: uac.Org,
- Bucket: uac.Bucket,
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
+ // FluxURL: uac.FluxURL,
+ // Token: uac.Token,
+ // Org: uac.Org,
+ // Bucket: uac.Bucket,
CervicesMap: make(map[string]*components.Cervice), // Initialize map
}
+ traits, err := UnmarshalTraits(configuredAsset.Traits)
+ if err != nil {
+ log.Println("Warning: could not unmarshal traits:", err)
+ } else if len(traits) > 0 {
+ ua.Traits = traits[0] // or handle multiple traits if needed
+ }
+
if ua.FluxURL == "" || ua.Token == "" || ua.Org == "" || ua.Bucket == "" {
log.Fatal("Invalid InfluxDB configuration: missing required parameters")
}
@@ -145,7 +165,7 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi
// Collect and ingest measurements
var wg sync.WaitGroup
sProtocols := components.SProtocols(sys.Husk.ProtoPort)
- for _, measurement := range uac.Measurements {
+ for _, measurement := range ua.Traits.Measurements {
// determine the protocols that the system supports
cMeasurement := components.Cervice{
Definition: measurement.Name,
@@ -173,6 +193,19 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi
}
}
+// UnmarshalTraits unmarshals a slice of json.RawMessage into a slice of Traits.
+func UnmarshalTraits(rawTraits []json.RawMessage) ([]Traits, error) {
+ var traitsList []Traits
+ for _, raw := range rawTraits {
+ var t Traits
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal trait: %w", err)
+ }
+ traitsList = append(traitsList, t)
+ }
+ return traitsList, nil
+}
+
//-------------------------------------Unit asset's functionalities
// collectIngest
diff --git a/ds18b20/ds18b20.go b/ds18b20/ds18b20.go
index 2ae678d..12c420a 100644
--- a/ds18b20/ds18b20.go
+++ b/ds18b20/ds18b20.go
@@ -18,6 +18,7 @@ package main
import (
"context"
+ "crypto/x509/pkix"
"encoding/json"
"fmt"
"log"
@@ -43,6 +44,14 @@ func main() {
Details: map[string][]string{"Developer": {"Synecdoque"}},
ProtoPort: map[string]int{"https": 0, "http": 20150, "coap": 0},
InfoLink: "https://github.com/sdoque/systems/tree/main/ds18b20",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
}
// instantiate a template unit asset
@@ -51,18 +60,20 @@ func main() {
sys.UAssets[assetName] = &assetTemplate
// Configure the system
- rawResources, servsTemp, err := usecases.Configure(&sys)
+ rawResources, err := usecases.Configure(&sys)
if err != nil {
log.Fatalf("configuration error: %v\n", err)
}
sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
+ var cleanups []func()
for _, raw := range rawResources {
- var uac UnitAsset
+ var uac usecases.ConfigurableAsset
if err := json.Unmarshal(raw, &uac); err != nil {
log.Fatalf("resource configuration error: %+v\n", err)
}
- ua, cleanup := newResource(uac, &sys, servsTemp)
- defer cleanup()
+ ua, cleanup := newResource(uac, &sys)
+ cleanups = append(cleanups, cleanup)
+ defer cleanup() // ensure cleanup is called when the program exits
sys.UAssets[ua.GetName()] = &ua
}
diff --git a/ds18b20/thing.go b/ds18b20/thing.go
index 348635e..7306b97 100644
--- a/ds18b20/thing.go
+++ b/ds18b20/thing.go
@@ -18,8 +18,9 @@ package main
import (
"context"
+ "encoding/json"
+ "fmt"
"log"
- "math"
"os"
"strconv"
"strings"
@@ -27,7 +28,7 @@ import (
"github.com/sdoque/mbaigo/components"
"github.com/sdoque/mbaigo/forms"
- "golang.org/x/exp/rand"
+ "github.com/sdoque/mbaigo/usecases"
)
// Define the types of requests the measurement manager can handle
@@ -37,7 +38,12 @@ type STray struct {
Error chan error
}
-//-------------------------------------Define the unit asset
+// -------------------------------------Define the unit asset
+// Traits are Asset-specific configurable parameters
+type Traits struct {
+ temperature float64 `json:"-"`
+ tStamp time.Time `json:"-"`
+}
// UnitAsset type models the unit asset (interface) of the system.
type UnitAsset struct {
@@ -47,9 +53,8 @@ type UnitAsset struct {
ServicesMap components.Services `json:"-"`
CervicesMap components.Cervices `json:"-"`
//
- temperature float64 `json:"-"`
- tStamp time.Time `json:"-"`
- trayChan chan STray `json:"-"` // Add a channel for temperature readings
+ Traits
+ trayChan chan STray `json:"-"` // Add a channel for temperature readings
}
// GetName returns the name of the Resource.
@@ -72,6 +77,11 @@ func (ua *UnitAsset) GetDetails() map[string][]string {
return ua.Details
}
+// GetTraits returns the traits of the Resource.
+func (ua *UnitAsset) GetTraits() any {
+ return ua.Traits
+}
+
// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation)
var _ components.UnitAsset = (*UnitAsset)(nil)
@@ -102,15 +112,21 @@ func initTemplate() components.UnitAsset {
//-------------------------------------Instantiate the unit assets based on configuration
// newResource creates the Resource resource with its pointers and channels based on the configuration
-func newResource(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) {
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
ua := &UnitAsset{ // this a struct that implements the UnitAsset interface
- Name: uac.Name,
+ Name: configuredAsset.Name,
Owner: sys,
- Details: uac.Details,
- ServicesMap: components.CloneServices(servs),
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
trayChan: make(chan STray), // Initialize the channel
}
+ traits, err := UnmarshalTraits(configuredAsset.Traits)
+ if err != nil {
+ log.Println("Warning: could not unmarshal traits:", err)
+ } else if len(traits) > 0 {
+ ua.Traits = traits[0] // or handle multiple traits if needed
+ }
// start the unit asset(s)
go ua.readTemperature(sys.Ctx)
@@ -119,14 +135,25 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi
}
}
+// UnmarshalTraits unmarshals a slice of json.RawMessage into a slice of Traits.
+func UnmarshalTraits(rawTraits []json.RawMessage) ([]Traits, error) {
+ var traitsList []Traits
+ for _, raw := range rawTraits {
+ var t Traits
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal trait: %w", err)
+ }
+ traitsList = append(traitsList, t)
+ }
+ return traitsList, nil
+}
+
//-------------------------------------Unit asset's functionalities
// readTemperature obtains the temperature from respective ds18b20 resource at regular intervals
func (ua *UnitAsset) readTemperature(ctx context.Context) {
defer close(ua.trayChan) // Ensure the channel is closed when the goroutine exits
- randomdDelay()
-
// Create a ticker that triggers every 2 seconds
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop() // Clean up the ticker when done
@@ -198,25 +225,3 @@ func (ua *UnitAsset) readTemperature(ctx context.Context) {
}
}
}
-
-// randomDelay is used to have the requests to multiple 1-wire sensor out of synch to free the bus. (This is a quick hack :-( )
-func randomdDelay() {
- rand.Seed(uint64(time.Now().UnixNano()))
-
- // Constants
- baseDelay := 93 * time.Millisecond // 0.093 seconds
- maxMultiples := int(math.Floor(1.0 / 0.093)) // Calculate the max multiples (10 in this case)
-
- // Generate a random multiplier (1 to maxMultiples - 1)
- randomMultiplier := rand.Intn(maxMultiples-1) + 1
-
- // Calculate the delay
- delay := time.Duration(randomMultiplier) * baseDelay
-
- log.Printf("Random delay: %v\n", delay)
-
- // Sleep for the random duration
- time.Sleep(delay)
-
- log.Println("Program resumed after delay.")
-}
diff --git a/esr/README.md b/esr/README.md
index 3e93ba9..47ce25d 100755
--- a/esr/README.md
+++ b/esr/README.md
@@ -11,23 +11,23 @@ If such tracking is necessary, it is best suited with the Modeler system with it
## Compilation
After cloning the *Systems repository*, you will need to go to the *esr* directory in the command line interface or terminal.
There, you will need to initialize the *go.mod* file for dependency tracking and version management (this is done only once).
-Type ```go mod init esr```.
+Type ```go mod init github.com/sdoque/systems/esr```.
As it generates the file, it will tell you to tidy it up with ```go mod tidy```.
If there are dependencies, (which you can list with ```go list -m all```), it will generate a *go.sum* file with the checksum of the downloaded dependencies for integrity verification.
-You can then compile your code with ```go build esr.go thing.go scheduler.go```.
+You can then execute the code with ```go run .```.
The first time, the program is ran, it will generate the *systemconfig.json*, which you can update if necessary.
Then restarting the program, the system will be up and running.
It will provide you with the URL of its web server, which you can access with a standard web browser.
## Cross compilation
-- Intel Mac: ```GOOS=darwin GOARCH=amd64 go build -o esr_imac esr.go thing.go scheduler.go```
-- ARM Mac: ```GOOS=darwin GOARCH=arm64 go build -o esr_amac esr.go thing.go scheduler.go```
-- Windows 64: ```GOOS=windows GOARCH=amd64 go build -o esr_win64.exe esr.go thing.go scheduler.go```
-- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o esr_rpi64 esr.go thing.go scheduler.go```
-- Linux: ```GOOS=linux GOARCH=amd64 go build -o esr_amd64 esr.go thing.go scheduler.go```
+- Intel Mac: ```GOOS=darwin GOARCH=amd64 go build -o esr_imac```
+- ARM Mac: ```GOOS=darwin GOARCH=arm64 go build -o esr_amac```
+- Windows 64: ```GOOS=windows GOARCH=amd64 go build -o esr_win64.exe```
+- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o esr_rpi64```
+- Linux: ```GOOS=linux GOARCH=amd64 go build -o esr_amd64```
## Testing shutdown
To test the graceful shutdown, one cannot use the IDE debugger but must use the terminal with
-```go run esr.go thing.go scheduler.go```
+```go run .```
Using the IDE debugger will allow one to test device failure, i.e. unplugging the computer.
\ No newline at end of file
diff --git a/esr/esr.go b/esr/esr.go
index 9bd4dce..75c86bd 100644
--- a/esr/esr.go
+++ b/esr/esr.go
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2024 Synecdoque
+ * Copyright (c) 2025 Synecdoque
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -18,6 +18,7 @@ package main
import (
"context"
+ "crypto/x509/pkix"
"encoding/json"
"fmt"
"io"
@@ -42,12 +43,20 @@ func main() {
// instantiate the System
sys := components.NewSystem("serviceregistrar", ctx)
- // Instatiate the Capusle
+ // Instantiate the Capsule
sys.Husk = &components.Husk{
Description: "is an Arrowhead mandatory core system that keeps track of the currently available services.",
Details: map[string][]string{"Developer": {"Synecdoque"}},
ProtoPort: map[string]int{"https": 0, "http": 20102, "coap": 0},
InfoLink: "https://github.com/sdoque/systems/tree/main/esr",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
}
// instantiate a template unit asset
@@ -56,18 +65,17 @@ func main() {
sys.UAssets[assetName] = &assetTemplate
// Configure the system
- rawResources, servsTemp, err := usecases.Configure(&sys)
+ rawResources, err := usecases.Configure(&sys)
if err != nil {
log.Fatalf("Configuration error: %v\n", err)
}
sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
-
for _, raw := range rawResources {
- var uac UnitAsset
+ var uac usecases.ConfigurableAsset
if err := json.Unmarshal(raw, &uac); err != nil {
log.Fatalf("Resource configuration error: %+v\n", err)
}
- ua, cleanup := newResource(uac, &sys, servsTemp) // a new unit asset with its own mutex
+ ua, cleanup := newResource(uac, &sys)
defer cleanup()
sys.UAssets[ua.GetName()] = &ua
}
@@ -83,9 +91,10 @@ func main() {
// wait for shutdown signal, and gracefully close properly goroutines with context
<-sys.Sigs // wait for a SIGINT (Ctrl+C) signal
- fmt.Println("\nshuting down system", sys.Name)
- cancel() // cancel the context, signaling the goroutines to stop
- time.Sleep(3 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end
+ fmt.Println("\nShutting down system", sys.Name)
+ cancel() // cancel the context, signaling the goroutines to stop
+ // allow the go routines to be executed, which might take more time than the main routine to end
+ time.Sleep(3 * time.Second)
}
// ---------------------------------------------------------------------------- end of main()
@@ -112,7 +121,9 @@ func (ua *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath
func (ua *UnitAsset) updateDB(w http.ResponseWriter, r *http.Request) {
if !ua.leading {
w.WriteHeader(http.StatusServiceUnavailable)
- w.Write([]byte("Service Unavailable"))
+ if _, err := w.Write([]byte("Service Unavailable")); err != nil {
+ log.Printf("error occurred while writing to responsewriter: %v", err)
+ }
return
}
switch r.Method {
@@ -120,19 +131,22 @@ func (ua *UnitAsset) updateDB(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
- fmt.Println("Error parsing media type:", err)
+ log.Println("Error parsing media type:", err)
+ http.Error(w, "Error parsing media type", http.StatusBadRequest)
return
}
defer r.Body.Close()
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
- log.Printf("error reading registration request body: %v", err)
+ log.Printf("Error reading registration request body: %v", err)
+ http.Error(w, "Error reading registration request body", http.StatusBadRequest)
return
}
record, err := usecases.Unpack(bodyBytes, mediaType)
if err != nil {
- log.Printf("error extracting the registration request %v\n", err)
+ log.Printf("Error extracting the registration request %v\n", err)
+ http.Error(w, "Error extracting the registration request", http.StatusBadRequest)
return
}
@@ -148,21 +162,21 @@ func (ua *UnitAsset) updateDB(w http.ResponseWriter, r *http.Request) {
// Check the error back from the unit asset
err = <-addRecord.Error
if err != nil {
- log.Printf("error adding the new service: %v", err)
+ log.Printf("Error adding the new service: %v", err)
http.Error(w, "Error registering service", http.StatusInternalServerError)
return
}
// fmt.Println(record)
updatedRecordBytes, err := usecases.Pack(record, mediaType)
if err != nil {
- log.Printf("error confirming new service: %s", err)
+ log.Printf("Error confirming new service: %s", err)
http.Error(w, "Error registering service", http.StatusInternalServerError)
}
w.Header().Set("Content-Type", mediaType)
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte(updatedRecordBytes))
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ log.Printf("Error occurred while writing to response: %v", err)
return
}
@@ -192,47 +206,58 @@ func (ua *UnitAsset) queryDB(w http.ResponseWriter, r *http.Request) {
log.Printf("Error retrieving service records: %v", err)
http.Error(w, "Error retrieving service records", http.StatusInternalServerError)
}
- case servvicesList := <-recordsRequest.Result:
+ case servicesList := <-recordsRequest.Result:
// Build the HTML response
text := "
"
- w.Write([]byte(text))
+ if _, err := w.Write([]byte(text)); err != nil {
+ log.Printf("Error occurred while writing to responsewriter: %v", err)
+ }
text = "The local cloud's currently available services are:
"
- w.Write([]byte(text))
- for _, serRec := range servvicesList {
+ if _, err := w.Write([]byte(text)); err != nil {
+ log.Printf("Error occurred while writing to responsewriter: %v", err)
+ }
+ for _, servRec := range servicesList {
metaservice := ""
- for key, values := range serRec.Details {
+ for key, values := range servRec.Details {
metaservice += key + ": " + fmt.Sprintf("%v", values) + " "
}
- hyperlink := "http://" + serRec.IPAddresses[0] + ":" + strconv.Itoa(int(serRec.ProtoPort["http"])) + "/" + serRec.SystemName + "/" + serRec.SubPath
- parts := strings.Split(serRec.SubPath, "/")
+ hyperlink := "http://" + servRec.IPAddresses[0] + ":" + strconv.Itoa(int(servRec.ProtoPort["http"])) + "/" + servRec.SystemName + "/" + servRec.SubPath
+ parts := strings.Split(servRec.SubPath, "/")
uaName := parts[0]
- sLine := "Service ID: " + strconv.Itoa(int(serRec.Id)) + " with definition " + serRec.ServiceDefinition + " from the " + serRec.SystemName + "/" + uaName + " with details " + metaservice + " will expire at: " + serRec.EndOfValidity + "
"
- w.Write([]byte(fmt.Sprintf("- %s
", sLine)))
+ sLine := "Service ID: " + strconv.Itoa(int(servRec.Id)) + " with definition " + servRec.ServiceDefinition + " from the " + servRec.SystemName + "/" + uaName + " with details " + metaservice + " will expire at: " + servRec.EndOfValidity + "
"
+ if _, err := w.Write([]byte(fmt.Sprintf("- %s
", sLine))); err != nil {
+ log.Printf("Error occurred while writing to responsewriter: %v", err)
+ }
}
text = "
"
- w.Write([]byte(text))
+ if _, err := w.Write([]byte(text)); err != nil {
+ log.Printf("Error occurred while writing to responsewriter: %v", err)
+ }
case <-time.After(5 * time.Second): // Optional timeout
http.Error(w, "Request timed out", http.StatusGatewayTimeout)
log.Println("Failure to process service listing request")
}
- case "POST": // from the orchesrator
+ case "POST": // from the orchestrator
contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
- fmt.Println("Error parsing media type:", err)
+ log.Println("Error parsing media type:", err)
+ http.Error(w, "Error parsing media type", http.StatusBadRequest)
return
}
defer r.Body.Close()
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
- log.Printf("error reading service discovery request body: %v", err)
+ log.Printf("Error reading service discovery request body: %v", err)
+ http.Error(w, "Error reading service discovery request body", http.StatusBadRequest)
return
}
record, err := usecases.Unpack(bodyBytes, mediaType)
if err != nil {
- log.Printf("error extracting the service discovery request %v\n", err)
+ log.Printf("Error extracting the service discovery request %v\n", err)
+ http.Error(w, "Error extracting the service discovery request", http.StatusBadRequest)
return
}
@@ -255,11 +280,10 @@ func (ua *UnitAsset) queryDB(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Error retrieving service records", http.StatusInternalServerError)
return
}
- case servvicesList := <-readRecord.Result:
- fmt.Println(servvicesList)
+ case servicesList := <-readRecord.Result:
var slForm forms.ServiceRecordList_v1
slForm.NewForm()
- slForm.List = servvicesList
+ slForm.List = servicesList
updatedRecordBytes, err := usecases.Pack(&slForm, mediaType)
if err != nil {
log.Printf("error confirming new service: %s", err)
@@ -273,14 +297,13 @@ func (ua *UnitAsset) queryDB(w http.ResponseWriter, r *http.Request) {
return
}
case <-time.After(5 * time.Second): // Optional timeout
- http.Error(w, "Request timed out", http.StatusGatewayTimeout)
log.Println("Failure to process service discovery request")
+ http.Error(w, "Request timed out", http.StatusGatewayTimeout)
return
}
default:
http.Error(w, "Unsupported HTTP request method", http.StatusMethodNotAllowed)
}
- // fmt.Println("Done querying the database")
}
// cleanDB deletes service records upon request (e.g., when a system shuts down)
@@ -307,7 +330,7 @@ func (ua *UnitAsset) cleanDB(w http.ResponseWriter, r *http.Request) {
// Check the error back from the unit asset
err = <-addRecord.Error
if err != nil {
- log.Printf("error deleting the service with id: %d, %s\n", id, err)
+ log.Printf("Error deleting the service with id: %d, %s\n", id, err)
http.Error(w, "Error deleting service", http.StatusInternalServerError)
return
}
@@ -331,9 +354,11 @@ func (ua *UnitAsset) roleStatus(w http.ResponseWriter, r *http.Request) {
return
}
w.WriteHeader(http.StatusServiceUnavailable)
- w.Write([]byte("Service Unavailable"))
+ if _, err := w.Write([]byte("Service Unavailable")); err != nil {
+ log.Printf("Error occurred while writing to responsewriter: %v", err)
+ }
default:
- fmt.Fprintf(w, "unsupported http request method")
+ fmt.Fprintf(w, "Unsupported http request method")
}
}
@@ -366,14 +391,14 @@ func (ua *UnitAsset) Role() {
case http.StatusServiceUnavailable:
// Service unavailable
default:
- fmt.Printf("Received unexpected status code: %d\n", resp.StatusCode)
+ log.Printf("Received unexpected status code: %d\n", resp.StatusCode)
}
}
if !standby && !ua.leading {
ua.leading = true
ua.leadingSince = time.Now()
ua.leadingRegistrar = nil
- fmt.Printf("taking the service registry lead at %s\n", ua.leadingSince)
+ log.Printf("Taking the service registry lead at %s\n", ua.leadingSince)
}
<-ticker.C
}
@@ -408,11 +433,11 @@ func (ua *UnitAsset) systemList(w http.ResponseWriter, r *http.Request) {
case "GET":
systemsList, err := getUniqueSystems(ua)
if err != nil {
- http.Error(w, fmt.Sprintf("system list error: %s", err), http.StatusInternalServerError)
+ http.Error(w, fmt.Sprintf("System list error: %s", err), http.StatusInternalServerError)
return
}
usecases.HTTPProcessGetRequest(w, r, systemsList)
default:
- http.Error(w, "unsupported HTTP request method", http.StatusMethodNotAllowed)
+ http.Error(w, "Unsupported HTTP request method", http.StatusMethodNotAllowed)
}
}
diff --git a/esr/esr_test.go b/esr/esr_test.go
new file mode 100644
index 0000000..89316ec
--- /dev/null
+++ b/esr/esr_test.go
@@ -0,0 +1,545 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+)
+
+// ----------------------------------------------- //
+// Help functions and structs to test roleStatus()
+// ----------------------------------------------- //
+
+func createLeadingRegistrar() *UnitAsset {
+ uac := &UnitAsset{
+ Name: "testRegistrar",
+ Details: map[string][]string{"testDetail": {"detail1", "detail2"}},
+ ServicesMap: components.Services{},
+
+ leading: true,
+ leadingSince: time.Now(),
+ }
+ return uac
+}
+
+func createNonLeadingRegistrar() *UnitAsset {
+ uac := &UnitAsset{
+ Name: "testRegistrar",
+ Details: map[string][]string{"testDetail": {"detail1", "detail2"}},
+ ServicesMap: components.Services{},
+
+ leading: false,
+ leadingRegistrar: &components.CoreSystem{Name: "otherRegistrar", Url: "otherURL"},
+ }
+ return uac
+}
+
+func createServiceUnavailableRegistrar() *UnitAsset {
+ uac := &UnitAsset{
+ Name: "testRegistrar",
+ Details: map[string][]string{"testDetail": {"detail1", "detail2"}},
+ ServicesMap: components.Services{},
+
+ leading: false,
+ leadingRegistrar: nil,
+ }
+ return uac
+}
+
+type roleStatusParams struct {
+ expectedStatuscode int
+ setup func() *UnitAsset
+ request *http.Request
+ testCase string
+}
+
+func TestRoleStatus(t *testing.T) {
+ params := []roleStatusParams{
+ {
+ 200,
+ func() *UnitAsset { return createLeadingRegistrar() },
+ httptest.NewRequest(http.MethodGet, "http://localhost/test", nil),
+ "Good case, leading registrar",
+ },
+ {
+ 503,
+ func() *UnitAsset { return createNonLeadingRegistrar() },
+ httptest.NewRequest(http.MethodGet, "http://localhost/test", nil),
+ "Good case, leading registrar",
+ },
+ {
+ 503,
+ func() *UnitAsset { return createServiceUnavailableRegistrar() },
+ httptest.NewRequest(http.MethodGet, "http://localhost/test", nil),
+ "Bad case, service unavailable",
+ },
+ {
+ 200,
+ func() *UnitAsset { return &UnitAsset{} },
+ httptest.NewRequest(http.MethodPost, "http://localhost/test", nil),
+ "Bad case, unsupported http method",
+ },
+ }
+ for _, c := range params {
+ ua := c.setup()
+ w := httptest.NewRecorder()
+ r := c.request
+
+ ua.roleStatus(w, r)
+ statusCode := w.Result().StatusCode
+ if statusCode != c.expectedStatuscode {
+ t.Errorf("Failed '%s', expected statuscode %d got: %d", c.testCase, c.expectedStatuscode, statusCode)
+ }
+ }
+}
+
+// ---------------------------------------------- //
+// Help functions and structs to test peersList()
+// ---------------------------------------------- //
+
+func createTestSysMultipleRegistrars(port string) components.System {
+ sys := createTestSystem()
+ sys.CoreS = []*components.CoreSystem{}
+ for num := range 5 {
+ reg := &components.CoreSystem{
+ Name: "serviceregistrar",
+ Url: fmt.Sprintf("http://localhost:%s/%d", port, num),
+ }
+ sys.CoreS = append(sys.CoreS, reg)
+ }
+ return sys
+}
+
+func createTestSysBrokenRegistrarURL() components.System {
+ sys := createTestSystem()
+ sys.CoreS = []*components.CoreSystem{}
+
+ reg := &components.CoreSystem{
+ Name: "serviceregistrar",
+ Url: string(rune(0)),
+ }
+ sys.CoreS = append(sys.CoreS, reg)
+
+ return sys
+}
+
+type peersListParams struct {
+ expectError bool
+ setup func() components.System
+ testCase string
+}
+
+func TestPeersList(t *testing.T) {
+ params := []peersListParams{
+ {
+ false,
+ func() (sys components.System) { return createTestSystem() },
+ "Good case, one registrar",
+ },
+ {
+ false,
+ func() (sys components.System) { return createTestSysMultipleRegistrars("1234") },
+ "Good case, multiple registrars",
+ },
+ {
+ false,
+ func() (sys components.System) { return createTestSysMultipleRegistrars("") },
+ "Bad case, port missing",
+ },
+ {
+ false,
+ func() (sys components.System) { return createTestSysMultipleRegistrars("8870") },
+ "Bad case, port same as http in husk",
+ },
+ {
+ true,
+ func() (sys components.System) { return createTestSysBrokenRegistrarURL() },
+ "Bad case, can't parse url",
+ },
+ }
+
+ for _, c := range params {
+ sys := c.setup()
+ _, err := peersList(&sys)
+ if (c.expectError == false) && (err != nil) {
+ t.Errorf("Expected no errors in '%s', got: %v", c.testCase, err)
+ }
+ if (c.expectError == true) && (err == nil) {
+ t.Errorf("Expected errors in '%s'", c.testCase)
+ }
+ }
+}
+
+// ----------------------------------------------- //
+// Help functions and structs to test systemList()
+// ----------------------------------------------- //
+
+func createFilledRegistrar() *UnitAsset {
+ ua := createLeadingRegistrar()
+ ua.serviceRegistry = make(map[int]forms.ServiceRecord_v1)
+ var serviceAmount int
+ for x := range 5 {
+ serviceAmount++
+ ua.serviceRegistry[x] = forms.ServiceRecord_v1{
+ Id: x,
+ SystemName: fmt.Sprintf("testSys%d", x),
+ IPAddresses: []string{"localhost"},
+ ProtoPort: map[string]int{"http": 1234},
+ }
+ }
+ return ua
+}
+
+type expectedBody struct {
+ List []string `json:"systemurl"`
+ Version string `json:"version"`
+}
+
+type systemListParams struct {
+ expectedStatuscode int
+ setup func() *UnitAsset
+ request *http.Request
+ testCase string
+}
+
+func TestSystemList(t *testing.T) {
+ params := []systemListParams{
+ {
+ 200,
+ func() *UnitAsset { return createFilledRegistrar() },
+ httptest.NewRequest(http.MethodGet, "http://localhost", nil),
+ "Best case",
+ },
+ {
+ 405,
+ func() *UnitAsset { return createFilledRegistrar() },
+ httptest.NewRequest(http.MethodPost, "http://localhost", nil),
+ "Bad case, unsupported http method",
+ },
+ }
+
+ for _, c := range params {
+ ua := c.setup()
+ w := httptest.NewRecorder()
+ r := c.request
+
+ ua.systemList(w, r)
+ res := w.Result()
+ data, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Errorf("Failed while reading response body")
+ }
+
+ var jsonData expectedBody
+ // Only unmarshal the data if it's a successful request
+ if res.StatusCode == 200 {
+ err = json.Unmarshal(data, &jsonData)
+ if err != nil {
+ t.Errorf("Failed while unmarshalling data")
+ }
+ }
+
+ if (res.StatusCode == 200) && (len(jsonData.List) != 5) {
+ t.Errorf("Expected status code '%d' and length of list '%d' got: '%d' and '%d'",
+ c.expectedStatuscode, 5, res.StatusCode, len(jsonData.List))
+ }
+
+ if c.expectedStatuscode == 405 && res.Status != "405 Method Not Allowed" {
+ t.Errorf("Expected '405 Method Not Allowed' as Status, got: %v", res.Status)
+ }
+ }
+}
+
+// ----------------------------------------------- //
+// Help functions and structs to test updateDB()
+// ----------------------------------------------- //
+
+func createSpecialRequest(statusCode int, method string) *http.Request {
+ if statusCode == 200 {
+ rec := &forms.ServiceRecord_v1{
+ Id: 0,
+ Version: "ServiceRecord_v1",
+ }
+
+ data, _ := json.Marshal(rec)
+ body := io.NopCloser(bytes.NewReader(data))
+ return httptest.NewRequest(method, "http://localhost/reg", body)
+ } else {
+ rec := &forms.ServiceRecord_v1{
+ Id: int(0),
+ ServiceDefinition: "test",
+ SystemName: "System",
+ ServiceNode: "node",
+ IPAddresses: []string{"123.456.789.012"},
+ ProtoPort: map[string]int{"http": 1234},
+ Details: map[string][]string{"details": {}},
+ Certificate: "ABCD",
+ SubPath: "testPath",
+ RegLife: 25,
+ Version: "SignalA_v1.0",
+ Created: "",
+ Updated: time.Now().String(),
+ EndOfValidity: time.Now().Add(25 * time.Second).String(),
+ SubscribeAble: false,
+ ACost: float64(0),
+ CUnit: "",
+ }
+ data, _ := json.Marshal(rec)
+ body := io.NopCloser(bytes.NewReader(data))
+ return httptest.NewRequest(method, "http://localhost/reg", body)
+ }
+}
+
+type updateDBParams struct {
+ expectedStatuscode int
+ leading bool
+ body io.ReadCloser
+ method string
+ testCase string
+}
+
+func TestUpdateDB(t *testing.T) {
+ params := []updateDBParams{
+ {
+ http.StatusServiceUnavailable,
+ false,
+ io.NopCloser(strings.NewReader("TestBody")),
+ http.MethodPut,
+ "Bad case, not leading registrar",
+ },
+ {
+ http.StatusBadRequest,
+ true,
+ io.NopCloser(strings.NewReader("TestBody")),
+ http.MethodPut,
+ "Bad case, wrong content type in request",
+ },
+ {
+ http.StatusBadRequest,
+ true,
+ io.NopCloser(errReader(0)),
+ http.MethodPut,
+ "Bad case, can't read body",
+ },
+ {
+ http.StatusBadRequest,
+ true,
+ io.NopCloser(strings.NewReader("")),
+ http.MethodPut,
+ "Bad case, can't unpack body",
+ },
+ {
+ http.StatusInternalServerError,
+ true,
+ nil,
+ http.MethodPut,
+ "Bad case, request returns error",
+ },
+ {
+ 200,
+ true,
+ nil,
+ http.MethodPost,
+ "Good case, everything passes",
+ },
+ {
+ 200,
+ true,
+ io.NopCloser(strings.NewReader("")),
+ http.MethodGet,
+ "Good case, default case",
+ },
+ }
+
+ for _, c := range params {
+ // Setup
+ var ua *UnitAsset
+ sys := createTestSystem()
+ confAsset := createConfAssetMultipleTraits()
+ temp, shutdown := newResource(confAsset, &sys)
+ ua = temp.(*UnitAsset)
+ ua.leading = c.leading
+ w := httptest.NewRecorder()
+ var r *http.Request
+ if c.body == nil {
+ r = createSpecialRequest(c.expectedStatuscode, c.method)
+ } else {
+ r = httptest.NewRequest(c.method, "http://localhost/reg", c.body)
+ }
+
+ r.Header = map[string][]string{"Content-Type": {"application/json"}}
+
+ // Test and checks
+ ua.updateDB(w, r)
+
+ if w.Result().StatusCode != c.expectedStatuscode {
+ t.Errorf("Expected statuscode %d, got: %d in '%s'",
+ c.expectedStatuscode, w.Result().StatusCode, c.testCase)
+ }
+
+ shutdown()
+ }
+}
+
+// ----------------------------------------------- //
+// Help functions and structs to test queryDB()
+// ----------------------------------------------- //
+
+type queryDBParams struct {
+ expectedStatuscode int
+ leading bool
+ body io.ReadCloser
+ method string
+ header map[string][]string
+ testCase string
+}
+
+func TestQueryDB(t *testing.T) {
+ params := []queryDBParams{
+ {
+ http.StatusOK,
+ true,
+ io.NopCloser(strings.NewReader("{}")),
+ http.MethodGet,
+ map[string][]string{"Content-Type": {"application/json"}},
+ "Good case GET, everything passes",
+ },
+ {
+ http.StatusBadRequest,
+ true,
+ io.NopCloser(strings.NewReader("{}")),
+ http.MethodPost,
+ map[string][]string{},
+ "Bad case POST, can't parse Content-Type from header",
+ },
+ {
+ http.StatusBadRequest,
+ true,
+ io.NopCloser(errReader(0)),
+ http.MethodPost,
+ map[string][]string{"Content-Type": {"application/json"}},
+ "Bad case POST, error while reading body",
+ },
+ {
+ http.StatusBadRequest,
+ true,
+ io.NopCloser(strings.NewReader("{}")),
+ http.MethodPost,
+ map[string][]string{"Content-Type": {"application/json"}},
+ "Bad case POST, error while unpacking body",
+ },
+ {
+ http.StatusInternalServerError,
+ true,
+ io.NopCloser(strings.NewReader(`{"id": 0, "version":"SignalA_v1.0"}`)),
+ http.MethodPost,
+ map[string][]string{"Content-Type": {"application/json"}},
+ "Bad case POST, request returns error",
+ },
+ {
+ http.StatusOK,
+ true,
+ io.NopCloser(strings.NewReader(`{"id": 0, "version":"ServiceQuest_v1"}`)),
+ http.MethodPost,
+ map[string][]string{"Content-Type": {"application/json"}},
+ "Good case POST, request returns a result",
+ },
+ {
+ http.StatusMethodNotAllowed,
+ true,
+ io.NopCloser(strings.NewReader(`{"id": 0, "version":"ServiceQuest_v1"}`)),
+ http.MethodDelete,
+ map[string][]string{"Content-Type": {"application/json"}},
+ "Bad case default, unsupported http method",
+ },
+ }
+
+ for _, c := range params {
+ // Setup
+ var ua *UnitAsset
+ sys := createTestSystem()
+ confAsset := createConfAssetMultipleTraits()
+ temp, shutdown := newResource(confAsset, &sys)
+ ua = temp.(*UnitAsset)
+ ua.leading = c.leading
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(c.method, "http://localhost/reg", c.body)
+ r.Header = c.header
+
+ sendAddRequest(0, "test", "testPath", "", ua.requests)
+
+ // Test and checks
+ ua.queryDB(w, r)
+
+ if w.Result().StatusCode != c.expectedStatuscode {
+ t.Errorf("Expected statuscode %d, got: %d in '%s'",
+ c.expectedStatuscode, w.Result().StatusCode, c.testCase)
+ }
+
+ shutdown()
+ }
+}
+
+// ----------------------------------------------- //
+// Help functions and structs to test cleanDB()
+// ----------------------------------------------- //
+
+type cleanDBParams struct {
+ expectedStatuscode int
+ leading bool
+ body io.ReadCloser
+ method string
+ testCase string
+}
+
+func TestCleanDB(t *testing.T) {
+ params := []cleanDBParams{
+ {
+ http.StatusBadRequest,
+ true,
+ io.NopCloser(strings.NewReader(`{"id": 0, "version":"ServiceQuest_v1"}`)),
+ http.MethodDelete,
+ "Bad case DELETE, couldn't convert id to int",
+ },
+ {
+ 200,
+ true,
+ io.NopCloser(strings.NewReader(`{"id": 0, "version":"ServiceQuest_v1"}`)),
+ http.MethodGet,
+ "Bad case default, unsupported http method",
+ },
+ }
+
+ for _, c := range params {
+ var ua *UnitAsset
+ sys := createTestSystem()
+ confAsset := createConfAssetMultipleTraits()
+ temp, shutdown := newResource(confAsset, &sys)
+ ua = temp.(*UnitAsset)
+ ua.leading = c.leading
+
+ w := httptest.NewRecorder()
+ r := httptest.NewRequest(c.method, "http://localhost/reg/a", c.body)
+ r.Header = map[string][]string{"Content-Type": {"application/json"}}
+ sendAddRequest(0, "test", "testPath", "", ua.requests)
+
+ // Test and checks
+ ua.cleanDB(w, r)
+
+ if w.Result().StatusCode != c.expectedStatuscode {
+ t.Errorf("Expected statuscode %d, got: %d in '%s'",
+ c.expectedStatuscode, w.Result().StatusCode, c.testCase)
+ }
+
+ shutdown()
+ }
+}
diff --git a/esr/go.mod b/esr/go.mod
new file mode 100644
index 0000000..83d563f
--- /dev/null
+++ b/esr/go.mod
@@ -0,0 +1,8 @@
+module github.com/sdoque/systems/esr
+
+go 1.24.4
+
+require github.com/sdoque/mbaigo v0.0.0-20250520155324-7390c339652a
+
+// Replaces this library with a patched version
+replace github.com/sdoque/mbaigo v0.0.0-20250520155324-7390c339652a => github.com/lmas/mbaigo v0.0.0-20250715100940-0fef178d190b
diff --git a/esr/go.sum b/esr/go.sum
new file mode 100644
index 0000000..ef3db4b
--- /dev/null
+++ b/esr/go.sum
@@ -0,0 +1,2 @@
+github.com/lmas/mbaigo v0.0.0-20250715100940-0fef178d190b h1:4I+X0TTj6E2RvaAIG8EV7TzZ9O4oPOOBJiWR/otOOJg=
+github.com/lmas/mbaigo v0.0.0-20250715100940-0fef178d190b/go.mod h1:vXE1mDd88Tap9bHm1elrk3Ht8bcImA3FeiSM03yUwsM=
diff --git a/esr/scheduler.go b/esr/scheduler.go
index 91a6340..130861a 100644
--- a/esr/scheduler.go
+++ b/esr/scheduler.go
@@ -17,131 +17,58 @@
package main
import (
- "container/heap"
+ "sync"
"time"
)
-//*********************Expired services cleaning scheduler*********************
-
-// cleaningTask holds the time for the next time a service is due to expire
-type cleaningTask struct {
- Deadline time.Time // the time when job has to be executed
- Job func() // call to check expiration of a record
- Id int // the job Id is the record id and is used to remove a scheduled task
-}
-
-// cleaningQueue the list of schedlued check on service expiration
-type cleaningQueue []*cleaningTask
-
-// Len returns the length of the service list and their expiration time
-func (cq cleaningQueue) Len() int { return len(cq) }
-
-// Less checks if a task is due sooner than another
-func (cq cleaningQueue) Less(i, j int) bool {
- return cq[i].Deadline.Before(cq[j].Deadline)
-}
-
-// Swap exchanges the order of task if one is due before the other
-func (cq cleaningQueue) Swap(i, j int) {
- cq[i], cq[j] = cq[j], cq[i]
-}
-
-// Push adds a task to the task list or queue
-func (cq *cleaningQueue) Push(x interface{}) {
- task := x.(*cleaningTask)
- *cq = append(*cq, task)
-}
-
-// Pop remove a task from the cleaning queue
-func (cq *cleaningQueue) Pop() interface{} {
- old := *cq
- n := len(old)
- task := old[n-1]
- *cq = old[0 : n-1]
- return task
-}
-
-// Scheduler struct type with the list and two channels
+// Scheduler struct type with the list and three channels
type Scheduler struct {
- taskQueue cleaningQueue
- taskStream chan *cleaningTask
- stopChan chan struct{}
+ taskMap map[int]*time.Timer // list elements has id, timer
+ mu sync.Mutex
}
-// NewScheduler creates a new scheduler
+// Returns a scheduler with an empty task map
func NewScheduler() *Scheduler {
return &Scheduler{
- taskStream: make(chan *cleaningTask),
- stopChan: make(chan struct{}),
+ taskMap: make(map[int]*time.Timer),
+ mu: sync.Mutex{},
}
}
-// AddTask adds a task to the queue with its deadline
+// AddTask adds a task to the task map and starts a timer for its job, when timer is done it runs the job in a goroutine
+// It's up to the caller to ensure that the deadline is not before time.Now()
func (s *Scheduler) AddTask(deadline time.Time, job func(), id int) {
- task := &cleaningTask{
- Deadline: deadline,
- Job: job,
- Id: id,
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ timer, exists := s.taskMap[id]
+ if exists {
+ timer.Stop()
}
- s.taskStream <- task
+ t := time.AfterFunc(time.Until(deadline), job)
+ s.taskMap[id] = t
}
-// RemoveTask removes a scheduled task
+// RemoveTask removes a scheduled job and deletes the task from the task map
func (s *Scheduler) RemoveTask(id int) bool {
- // Search for the task with the given Id
- for i, task := range s.taskQueue {
- if task.Id == id {
- // Remove the task from the queue
- s.taskQueue = append(s.taskQueue[:i], s.taskQueue[i+1:]...)
- heap.Init(&s.taskQueue) // Reinitialize the heap
- return true // Return true indicating the task was found and removed
- }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ timer, exists := s.taskMap[id]
+ if !exists {
+ return false
}
- return false // Return false if the task wasn't found
+ timer.Stop()
+ delete(s.taskMap, id)
+ return true
}
-// run is the goroutine that cleans up expired services by continuously checking that end of validity of services
-func (s *Scheduler) run() {
- var timer *time.Timer
- defer s.Stop()
- for {
- if len(s.taskQueue) > 0 {
- nextTask := s.taskQueue[0]
- if timer == nil {
- timer = time.NewTimer(time.Until(nextTask.Deadline))
- } else {
- timer.Reset(time.Until(nextTask.Deadline))
- }
- }
-
- time.Sleep(10 * time.Millisecond) // this is used to reduce CPU consumption otherwise the go routine is a "short circuit" with no resistance
-
- select {
- case task := <-s.taskStream:
- heap.Push(&s.taskQueue, task)
- if timer == nil {
- timer = time.NewTimer(time.Until(task.Deadline))
- } else {
- timer.Reset(time.Until(task.Deadline))
- }
- case <-func() <-chan time.Time {
- if timer != nil {
- return timer.C
- }
- return nil
- }():
- task := heap.Pop(&s.taskQueue).(*cleaningTask)
- go task.Job()
- case <-s.stopChan:
- if timer != nil {
- timer.Stop()
- }
- return
- }
+// Stop() loops through the task map and turns off the timer for each tasks job
+func (s *Scheduler) Stop() (counter int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for _, timer := range s.taskMap {
+ timer.Stop()
+ counter++
}
-}
-
-// Stop terminnates the scheduler
-func (s *Scheduler) Stop() {
- s.stopChan <- struct{}{}
+ s.taskMap = make(map[int]*time.Timer)
+ return
}
diff --git a/esr/scheduler_test.go b/esr/scheduler_test.go
new file mode 100644
index 0000000..9962f11
--- /dev/null
+++ b/esr/scheduler_test.go
@@ -0,0 +1,82 @@
+package main
+
+import (
+ "testing"
+ "time"
+)
+
+// -------------------- //
+// Tests for scheduler
+// -------------------- //
+
+func TestAddTask(t *testing.T) {
+ sched := NewScheduler()
+ now := time.Now()
+ ch := make(chan int)
+
+ // case: ensure chrono order
+ sched.AddTask(now.Add(2*time.Second), func() { ch <- 0 }, 0)
+ sched.AddTask(now.Add(5*time.Millisecond), func() { ch <- 1 }, 1)
+ select {
+ case id := <-ch:
+ if id != 1 {
+ t.Errorf("Expected 1 from channel, got %d", id)
+ }
+ case <-time.After(50 * time.Millisecond):
+ t.Errorf("Chronological order test timed out")
+ }
+ sched.Stop()
+
+ // Case: ID is reused between tasks
+ sched.AddTask(now.Add(2*time.Second), func() { ch <- 0 }, 0)
+ sched.AddTask(now.Add(25*time.Millisecond), func() { ch <- 1 }, 0)
+ select {
+ case id := <-ch:
+ if id != 1 {
+ t.Errorf("Expected 1 from channel, got %d", id)
+ }
+ case <-time.After(50 * time.Millisecond):
+ t.Errorf("Duplicate ID test timed out")
+ }
+ sched.Stop()
+}
+
+func TestRemoveTask(t *testing.T) {
+ // Case: task exists
+ sched := NewScheduler()
+ now := time.Now()
+
+ // Add the task and then remove it, function should return true since it removed a task
+ sched.AddTask(now.Add(25*time.Second), func() {}, 0)
+
+ if removed := sched.RemoveTask(0); removed != true {
+ t.Errorf("Expected function to return true")
+ }
+
+ if _, exists := sched.taskMap[0]; exists {
+ t.Errorf("Expected no element in taskMap[0]")
+ }
+
+ // Case: task doesn't exist, function should return false since there was no task to remove
+ sched = NewScheduler()
+ // Add the task and then remove it
+ if removed := sched.RemoveTask(0); removed == true {
+ t.Errorf("Expected function to return false")
+ }
+}
+
+func TestStop(t *testing.T) {
+ sched := NewScheduler()
+ now := time.Now()
+
+ // Add some tasks and make sure Stop() works as intended
+ sched.AddTask(now.Add(25*time.Second), func() {}, 0)
+ sched.AddTask(now.Add(25*time.Second), func() {}, 1)
+ sched.AddTask(now.Add(25*time.Second), func() {}, 2)
+ sched.AddTask(now.Add(25*time.Second), func() {}, 3)
+ count := sched.Stop()
+
+ if count < 4 {
+ t.Errorf("Expected scheduler to turn off 4 tasks, got %d", count)
+ }
+}
diff --git a/esr/thing.go b/esr/thing.go
index 2009afc..293d107 100644
--- a/esr/thing.go
+++ b/esr/thing.go
@@ -20,6 +20,7 @@ import (
"errors"
"fmt"
"log"
+ "slices"
"strconv"
"sync"
"sync/atomic"
@@ -36,11 +37,9 @@ type ServiceRegistryRequest struct {
Record forms.Form
Id int64
Result chan []forms.ServiceRecord_v1 // For returning records
- Error chan error // For error handling
+ Error chan error
}
-//-------------------------------------Define the unit asset
-
// UnitAsset type models the unit asset (interface) of the system
type UnitAsset struct {
Name string `json:"name"`
@@ -50,13 +49,13 @@ type UnitAsset struct {
CervicesMap components.Cervices `json:"-"`
//
serviceRegistry map[int]forms.ServiceRecord_v1
- mu sync.Mutex
recCount int64
requests chan ServiceRegistryRequest
sched *Scheduler
leading bool
leadingSince time.Time
leadingRegistrar *components.CoreSystem // if not leading this points to the current leader
+ mu sync.Mutex
}
// GetName returns the name of the Resource.
@@ -115,7 +114,7 @@ func initTemplate() components.UnitAsset {
Description: "reports (GET) the role of the Service Registrar as leading or on stand by",
}
- // var uat components.UnitAsset // this is an interface, which we then initialize
+ // Create the UnitAsset with the defined services
uat := &UnitAsset{
Name: "registry",
Details: map[string][]string{"Location": {"LocalCloud"}},
@@ -132,31 +131,34 @@ func initTemplate() components.UnitAsset {
//-------------------------------------Instantiate unit asset(s) based on configuration
// newResource creates the unit asset with its pointers and channels based on the configuration using the uaConfig structs
-func newResource(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) {
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
// Start the registration expiration check scheduler
cleaningScheduler := NewScheduler()
- go cleaningScheduler.run()
// Initialize the UnitAsset
ua := &UnitAsset{
- Name: uac.Name,
- Owner: sys,
- Details: uac.Details,
- serviceRegistry: make(map[int]forms.ServiceRecord_v1),
- recCount: 1, // 0 is used for non registered services
- sched: cleaningScheduler,
- ServicesMap: components.CloneServices(servs),
- requests: make(chan ServiceRegistryRequest), // Initialize the requests channel
+ Name: configuredAsset.Name,
+ Owner: sys,
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
}
- ua.Role() // Start to repeatedly check which is the leading registrar
+ ua.serviceRegistry = make(map[int]forms.ServiceRecord_v1)
+ ua.recCount = 1 // 0 is used for non registered services
+ ua.sched = cleaningScheduler
+ ua.requests = make(chan ServiceRegistryRequest) // Initialize the requests channel
+
+ // Start to repeatedly check which is the leading registrar
+ ua.Role()
// Start the service registry manager goroutine
go ua.serviceRegistryHandler()
return ua, func() {
+ ua.mu.Lock()
close(ua.requests) // Close channels before exiting (cleanup)
cleaningScheduler.Stop() // Gracefully stop the scheduler
+ ua.mu.Unlock()
log.Println("Closing the service registry database connection")
}
}
@@ -180,6 +182,11 @@ func (ua *UnitAsset) serviceRegistryHandler() {
}
ua.mu.Lock() // Lock the serviceRegistry map
+ // Check if the ID exists in the serviceRegistry
+ if _, exists := ua.serviceRegistry[rec.Id]; !exists {
+ rec.Id = 0
+ }
+
if rec.Id == 0 {
// In the case recCount had looped, check that there is no record at that position
for {
@@ -201,11 +208,6 @@ func (ua *UnitAsset) serviceRegistryHandler() {
log.Printf("The new service %s from system %s has been registered\n", rec.ServiceDefinition, rec.SystemName)
} else {
// Validate and update existing record
- _, exists := ua.serviceRegistry[rec.Id]
- if !exists {
- ua.mu.Unlock()
- continue
- }
dbRec := ua.serviceRegistry[rec.Id]
if dbRec.ServiceDefinition != rec.ServiceDefinition {
request.Error <- errors.New("mismatch between definition received record and database record")
@@ -236,7 +238,6 @@ func (ua *UnitAsset) serviceRegistryHandler() {
}
nextExpiration := now.Add(time.Duration(dbRec.RegLife) * time.Second).Format(time.RFC3339)
rec.EndOfValidity = nextExpiration
- // log.Printf("Updated the record %s with next expiration date at %s", rec.ServiceDefinition, rec.EndOfValidity)
}
ua.sched.AddTask(now.Add(time.Duration(rec.RegLife)*time.Second), func() { checkExpiration(ua, rec.Id) }, rec.Id)
ua.serviceRegistry[rec.Id] = *rec // Add record to the registry
@@ -254,30 +255,40 @@ func (ua *UnitAsset) serviceRegistryHandler() {
}
ua.mu.Unlock() // Unlock access to the service registry map
request.Result <- result
- // log.Println("complete listing sent from registry")
continue
}
qform, ok := request.Record.(*forms.ServiceQuest_v1)
if !ok {
- fmt.Println("Problem unpacking the service quest request")
+ log.Println("Problem unpacking the service quest request")
request.Error <- fmt.Errorf("invalid record type")
continue
}
- fmt.Printf("\nThe service quest form is %v\n\n", qform)
matchingRecords := ua.FilterByServiceDefinitionAndDetails(qform.ServiceDefinition, qform.Details)
request.Result <- matchingRecords
case "delete":
// Handle delete record
+ ua.mu.Lock()
+ ua.sched.RemoveTask(int(request.Id))
delete(ua.serviceRegistry, int(request.Id))
if _, exists := ua.serviceRegistry[int(request.Id)]; !exists {
log.Printf("The service with ID %d has been deleted.", request.Id)
}
+ ua.mu.Unlock()
request.Error <- nil // Send success response
}
}
}
+func compareDetails(reqDetails []string, availDetails []string) bool {
+ for _, requiredValue := range reqDetails {
+ if slices.Contains(availDetails, requiredValue) {
+ return true
+ }
+ }
+ return false
+}
+
// FilterByServiceDefinitionAndDetails returns a list of services with the given service definition and details TODO: protocols
func (ua *UnitAsset) FilterByServiceDefinitionAndDetails(desiredDefinition string, requiredDetails map[string][]string) []forms.ServiceRecord_v1 {
ua.mu.Lock() // Ensure thread safety
@@ -298,20 +309,7 @@ func (ua *UnitAsset) FilterByServiceDefinitionAndDetails(desiredDefinition strin
}
// Ensure at least one value in requiredDetails matches record.Details
- valueMatch := false
- for _, requiredValue := range values {
- for _, recordValue := range recordValues {
- if recordValue == requiredValue {
- valueMatch = true
- break
- }
- }
- if valueMatch {
- break
- }
- }
-
- if !valueMatch {
+ if !compareDetails(values, recordValues) {
matchesAllDetails = false
break
}
@@ -328,10 +326,12 @@ func (ua *UnitAsset) FilterByServiceDefinitionAndDetails(desiredDefinition strin
// checkExpiration checks if a service has expired and deletes it if it has.
func checkExpiration(ua *UnitAsset, servId int) {
+ ua.mu.Lock()
+ defer ua.mu.Unlock()
dbRec := ua.serviceRegistry[servId]
expiration, err := time.Parse(time.RFC3339, dbRec.EndOfValidity)
if err != nil {
- log.Printf("time parsing problem when checking service expiration")
+ log.Printf("Time parsing problem when checking service expiration")
return
}
@@ -340,6 +340,7 @@ func checkExpiration(ua *UnitAsset, servId int) {
return
}
delete(ua.serviceRegistry, int(servId))
+ ua.sched.RemoveTask(int(servId))
if _, exists := ua.serviceRegistry[servId]; !exists {
log.Printf("The service with ID %d has been deleted because it was not renewed.", servId)
}
@@ -353,14 +354,13 @@ func getUniqueSystems(ua *UnitAsset) (*forms.SystemRecordList_v1, error) {
ua.mu.Lock() // Ensure thread safety
defer ua.mu.Unlock()
-
for _, record := range ua.serviceRegistry {
var sAddress string
// Check for HTTPS
- if port, exists := record.ProtoPort["https"]; exists && port != 0 {
+ if port := record.ProtoPort["https"]; port != 0 {
sAddress = "https://" + record.IPAddresses[0] + ":" + strconv.Itoa(port) + "/" + record.SystemName
- } else if port, exists := record.ProtoPort["http"]; exists && port != 0 { // Check for HTTP
+ } else if port := record.ProtoPort["http"]; port != 0 { // Check for HTTP
sAddress = "http://" + record.IPAddresses[0] + ":" + strconv.Itoa(port) + "/" + record.SystemName
} else {
fmt.Printf("Warning: %s cannot be modeled\n", record.SystemName)
diff --git a/esr/thing_test.go b/esr/thing_test.go
new file mode 100644
index 0000000..6a96d1a
--- /dev/null
+++ b/esr/thing_test.go
@@ -0,0 +1,679 @@
+package main
+
+import (
+ "context"
+ "crypto/x509/pkix"
+ "encoding/json"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+// ------------------------------------------------ //
+// Help functions and other goodies for testing
+// ------------------------------------------------ //
+
+// Create a error reader to break json.Unmarshal()
+type errReader int
+
+var errBodyRead error = fmt.Errorf("bad body read")
+
+func (errReader) Read(p []byte) (n int, err error) {
+ return 0, errBodyRead
+}
+func (errReader) Close() error {
+ return nil
+}
+
+func createConfAssetMultipleTraits() usecases.ConfigurableAsset {
+ uac := usecases.ConfigurableAsset{
+ Name: "testRegistrar",
+ Details: map[string][]string{"testDetail": {"detail1", "detail2"}},
+ Services: []components.Service{},
+ Traits: []json.RawMessage{json.RawMessage(`{"recCount": 0}`), json.RawMessage(`{"leading": false}`)},
+ }
+ return uac
+}
+
+func createTestSystem() components.System {
+ ctx := context.Background()
+ sys := components.NewSystem("testsys", ctx)
+ sys.Husk = &components.Husk{
+ Description: " is for testing purposes",
+ Certificate: "ABCD",
+ Details: map[string][]string{"Developer": {"Arrowhead"}},
+ ProtoPort: map[string]int{"https": 0, "http": 8870, "coap": 0},
+ InfoLink: "https://for.testing.purposes",
+ }
+ leadingRegistrar := &components.CoreSystem{
+ Name: components.ServiceRegistrarName,
+ Url: "https://leadingregistrar:1234",
+ }
+ orchestrator := &components.CoreSystem{
+ Name: "orchestrator",
+ Url: "https://orchestator:1234",
+ }
+ sys.CoreS = []*components.CoreSystem{
+ leadingRegistrar,
+ orchestrator,
+ }
+ return sys
+}
+
+// --------------------------------------------------------------------------- //
+// Help functions and structs to test the add part of serviceRegistryHandler()
+// --------------------------------------------------------------------------- //
+
+func createNewSys() components.System {
+ // prepare for graceful shutdown
+ ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled
+ defer cancel() // make sure all paths cancel the context to avoid context leak
+
+ // instantiate the System
+ sys := components.NewSystem("serviceregistrar", ctx)
+
+ // Instantiate the Capsule
+ sys.Husk = &components.Husk{
+ Description: "is an Arrowhead mandatory core system that keeps track of the currently available services.",
+ Details: map[string][]string{"Developer": {"Synecdoque"}},
+ ProtoPort: map[string]int{"https": 0, "http": 20102, "coap": 0},
+ InfoLink: "https://github.com/sdoque/systems/tree/main/esr",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
+ }
+
+ // instantiate a template unit asset
+ assetTemplate := initTemplate()
+ assetName := assetTemplate.GetName()
+ sys.UAssets[assetName] = &assetTemplate
+ return sys
+}
+
+func sendAddRequest(id int64, def string, subPath string, created string, ch chan ServiceRegistryRequest) error {
+ rec := &forms.ServiceRecord_v1{
+ Id: int(id),
+ ServiceDefinition: def,
+ SystemName: "System",
+ ServiceNode: "node",
+ IPAddresses: []string{"123.456.789.012"},
+ ProtoPort: map[string]int{"http": 1234},
+ Details: map[string][]string{"details": {}},
+ Certificate: "ABCD",
+ SubPath: subPath,
+ RegLife: 25,
+ Version: "SignalA_v1a",
+ Created: created,
+ Updated: time.Now().String(),
+ EndOfValidity: time.Now().Add(25 * time.Second).String(),
+ SubscribeAble: false,
+ ACost: float64(id),
+ CUnit: "",
+ }
+
+ req := ServiceRegistryRequest{
+ Action: "add",
+ Record: rec,
+ Error: make(chan error),
+ }
+
+ ch <- req
+
+ if err := <-req.Error; err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func sendBrokenAddRequest(num int64, ch chan ServiceRegistryRequest) error {
+ rec := &forms.SignalA_v1a{}
+ req := ServiceRegistryRequest{
+ Action: "add",
+ Record: rec,
+ Id: num,
+ Error: make(chan error),
+ }
+ ch <- req
+
+ if err := <-req.Error; err != nil {
+ return err
+ }
+
+ return nil
+}
+
+type serviceRegistryHandlerParams struct {
+ expectError bool
+ request func(*UnitAsset) error
+ testCase string
+}
+
+func TestServiceRegistryHandlerAdd(t *testing.T) {
+ params := []serviceRegistryHandlerParams{
+ {
+ false,
+ func(ua *UnitAsset) error {
+ return sendAddRequest(0, "testDef", "subP", time.Now().Format(time.RFC3339), ua.requests)
+ },
+ "Best case, successful request",
+ },
+ {
+ true,
+ func(ua *UnitAsset) error { return sendBrokenAddRequest(0, ua.requests) },
+ "Bad case, unable to convert to correct form",
+ },
+ {
+ true,
+ func(ua *UnitAsset) error {
+ err := sendAddRequest(0, "testDef", "subP", time.Now().Format(time.RFC3339), ua.requests)
+ if err != nil {
+ t.Fatalf("Failed sending first request")
+ }
+ err = sendAddRequest(1, "testDef2", "subP", time.Now().Format(time.RFC3339), ua.requests)
+ return err
+ },
+ "Bad case, exists with different service definition",
+ },
+ {
+ true,
+ func(ua *UnitAsset) error {
+ err := sendAddRequest(0, "testDef", "subP", time.Now().Format(time.RFC3339), ua.requests)
+ if err != nil {
+ t.Fatalf("Failed sending first request")
+ }
+ err = sendAddRequest(1, "testDef", "subPa", time.Now().Format(time.RFC3339), ua.requests)
+ return err
+ },
+ "Bad case, exists with different subpath",
+ },
+ {
+ true,
+ func(ua *UnitAsset) error {
+ err := sendAddRequest(0, "testDef", "subP", time.Now().Format(time.RFC3339), ua.requests)
+ if err != nil {
+ t.Fatalf("Failed sending first request")
+ }
+ err = sendAddRequest(1, "testDef", "subP", "", ua.requests)
+ return err
+ },
+ "Bad case, exists different creation time in updated record",
+ },
+ {
+ true,
+ func(ua *UnitAsset) error {
+ ch := ua.requests
+ err := sendAddRequest(0, "testDef", "subP", time.Now().Format(time.RFC3339), ch)
+ if err != nil {
+ t.Fatalf("Failed sending first request")
+ }
+ err = sendAddRequest(1, "testDef", "subP", time.Now().Add(1*time.Hour).Format(time.RFC3339), ch)
+ return err
+ },
+ "Bad case, mismatch between db- and received created field",
+ },
+ {
+ false,
+ func(ua *UnitAsset) error {
+ ch := ua.requests
+ err := sendAddRequest(0, "testDef", "subP", time.Now().Format(time.RFC3339), ch)
+ if err != nil {
+ t.Fatalf("Failed sending first request")
+ }
+ err = sendAddRequest(0, "testDef", "subP", time.Now().Format(time.RFC3339), ch)
+ return err
+ },
+ "Good case, recCount has looped back to 0",
+ },
+ {
+ false,
+ func(ua *UnitAsset) error {
+ ch := ua.requests
+ err := sendAddRequest(0, "testDef", "subP", time.Now().Format(time.RFC3339), ch)
+ if err != nil {
+ t.Fatalf("Failed sending first request")
+ }
+ err = sendAddRequest(1, "testDef", "subP", time.Now().Format(time.RFC3339), ch)
+ return err
+ },
+ "Good case, updated db record",
+ },
+ }
+
+ for _, c := range params {
+ // Setup
+ temp := createConfAssetMultipleTraits()
+ sys := createNewSys()
+ res, shutdown := newResource(temp, &sys)
+ ua, _ := res.(*UnitAsset)
+
+ // Test and check
+ err := c.request(ua)
+
+ if c.expectError == false && err != nil {
+ t.Errorf("Expected no errors in '%s': %v", c.testCase, err)
+ }
+ if c.expectError == true && err == nil {
+ t.Errorf("Expected errors in '%s'", c.testCase)
+ }
+ shutdown()
+ }
+}
+
+// --------------------------------------------------------------------------- //
+// Help functions and structs to test the read part of serviceRegistryHandler()
+// --------------------------------------------------------------------------- //
+
+func sendAddRequestWithDetails(id int64, def string, subPath string, created string, ch chan ServiceRegistryRequest) error {
+ rec := &forms.ServiceRecord_v1{
+ Id: int(id),
+ ServiceDefinition: def,
+ SystemName: "System",
+ ServiceNode: "node",
+ IPAddresses: []string{"123.456.789.012"},
+ ProtoPort: map[string]int{"http": 1234},
+ Details: map[string][]string{"details": {}},
+ Certificate: "ABCD",
+ SubPath: subPath,
+ RegLife: 25,
+ Version: "SignalA_v1a",
+ Created: created,
+ Updated: time.Now().String(),
+ EndOfValidity: time.Now().Add(25 * time.Second).String(),
+ SubscribeAble: false,
+ ACost: float64(id),
+ CUnit: "",
+ }
+
+ for x := range id {
+ rec.Details["details"] = append(rec.Details["details"], fmt.Sprintf("detail%d", x+1))
+ }
+
+ req := ServiceRegistryRequest{
+ Action: "add",
+ Id: 0,
+ Record: rec,
+ Error: make(chan error),
+ }
+
+ ch <- req
+ if err := <-req.Error; err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// id 0 will return all items in service registry, any other will return items depending on details & definition
+func sendReadRequest(id int64, def string, details []string, ch chan ServiceRegistryRequest) ([]forms.ServiceRecord_v1, error) {
+ rec := &forms.ServiceQuest_v1{
+ SysId: 999,
+ RequesterName: "requester",
+ ServiceDefinition: def,
+ Protocol: "",
+ Details: map[string][]string{"details": details},
+ Version: "",
+ }
+ var req ServiceRegistryRequest
+ if id == 0 {
+ // Returns a specific
+ req = ServiceRegistryRequest{
+ Action: "read",
+ Record: nil,
+ Result: make(chan []forms.ServiceRecord_v1),
+ Error: make(chan error),
+ }
+ } else {
+ // Returns full list of services
+ req = ServiceRegistryRequest{
+ Action: "read",
+ Record: rec,
+ Result: make(chan []forms.ServiceRecord_v1),
+ Error: make(chan error),
+ }
+ }
+
+ ch <- req
+ select {
+ case err := <-req.Error:
+ return nil, err
+ case lst := <-req.Result:
+ return lst, nil
+ }
+}
+
+func sendBrokenReadRequest(ch chan ServiceRegistryRequest) ([]forms.ServiceRecord_v1, error) {
+ rec := &forms.SignalA_v1a{}
+
+ var req = ServiceRegistryRequest{
+ Action: "read",
+ Record: rec,
+ Result: make(chan []forms.ServiceRecord_v1),
+ Error: make(chan error),
+ }
+
+ ch <- req
+ select {
+ case err := <-req.Error:
+ return nil, err
+ case lst := <-req.Result:
+ return lst, nil
+ }
+}
+
+type serviceRegistryHandlerReadParams struct {
+ expectError bool
+ expectedLen int
+ request func(ua *UnitAsset) ([]forms.ServiceRecord_v1, error)
+ testCase string
+}
+
+func TestServiceRegistryHandlerRead(t *testing.T) {
+ params := []serviceRegistryHandlerReadParams{
+ {
+ false,
+ 1,
+ func(ua *UnitAsset) ([]forms.ServiceRecord_v1, error) {
+ return sendReadRequest(0, "", []string{""}, ua.requests)
+ },
+ "Best case, successful read request returning all items",
+ },
+ {
+ false,
+ 1,
+ func(ua *UnitAsset) ([]forms.ServiceRecord_v1, error) {
+ return sendReadRequest(1, "test", []string{"detail6"}, ua.requests)
+ },
+ "Best case, successful read request returning specific items",
+ },
+ {
+ true,
+ 0,
+ func(ua *UnitAsset) ([]forms.ServiceRecord_v1, error) {
+ return sendBrokenReadRequest(ua.requests)
+ },
+ "Bad case, wrong form",
+ },
+ }
+
+ for _, c := range params {
+ // Setup
+ temp := createConfAssetMultipleTraits()
+ sys := createNewSys()
+ res, shutdown := newResource(temp, &sys)
+ ua, _ := res.(*UnitAsset)
+ time.Sleep(25 * time.Millisecond)
+ // Add some services to the serviceregistrar with details: detail1 detail2 ... detailN
+ sendAddRequestWithDetails(1, "test", "sub1", time.Now().Format(time.RFC3339), ua.requests)
+ sendAddRequestWithDetails(4, "test", "sub2", time.Now().Format(time.RFC3339), ua.requests)
+ sendAddRequestWithDetails(8, "test", "sub3", time.Now().Format(time.RFC3339), ua.requests)
+
+ lst, err := c.request(ua)
+
+ if c.expectError == false && err != nil && len(lst) != c.expectedLen {
+ t.Errorf("Expected no errors in '%s', got: %v, with length of list: %d got %d",
+ c.testCase, err, c.expectedLen, len(lst))
+ }
+ if c.expectError == true && err == nil {
+ t.Errorf("Expected errors in '%s'", c.testCase)
+ }
+
+ shutdown()
+ }
+}
+
+// ------------------------------------------------------------------------ //
+// Help functions and structs to test delete in serviceRegistryHandler()
+// ------------------------------------------------------------------------ //
+
+func sendDeleteRequest(id int, ch chan ServiceRegistryRequest) {
+ ch <- ServiceRegistryRequest{
+ Action: "delete",
+ Id: int64(id),
+ }
+}
+
+func TestServiceRegistryHandlerDelete(t *testing.T) {
+ // Setup
+ temp := createConfAssetMultipleTraits()
+ sys := createNewSys()
+ res, shutdown := newResource(temp, &sys)
+ ua, _ := res.(*UnitAsset)
+ time.Sleep(25 * time.Millisecond)
+ // Add a services to the serviceregistrar
+ sendAddRequestWithDetails(1, "test", "sub1", time.Now().Format(time.RFC3339), ua.requests)
+
+ sendDeleteRequest(0, ua.requests)
+
+ shutdown()
+}
+
+// ------------------------------------------------------------------------ //
+// Help functions and structs to test FilterByServiceDefinitionAndDetails()
+// ------------------------------------------------------------------------ //
+
+// Creates an asset multiple services in its registry
+func createRegistryWithServices(broken bool) (ua *UnitAsset, err error) {
+ initTemp := initTemplate()
+ ua, ok := initTemp.(*UnitAsset)
+ if !ok {
+ return nil, fmt.Errorf("Failed while typecasting to local UnitAsset")
+ }
+
+ var locations = []string{"Kitchen", "Bathroom", "Livingroom"}
+
+ ua.serviceRegistry = make(map[int]forms.ServiceRecord_v1)
+ for i, location := range locations {
+ var form forms.ServiceRecord_v1
+ form.ServiceDefinition = "testDef"
+ form.SystemName = fmt.Sprintf("testSystem%d", i)
+ form.ProtoPort = map[string]int{"http": i}
+ form.IPAddresses = []string{fmt.Sprintf("999.999.%d.999", i)}
+ form.EndOfValidity = "2026-01-02T15:04:05Z"
+ form.Details = make(map[string][]string)
+ if !broken {
+ form.Details = map[string][]string{"Location": {location}}
+ }
+ ua.serviceRegistry[i] = form
+ }
+ return ua, nil
+}
+
+type filterByServDefAndDetailsParams struct {
+ expectMatch bool
+ setup func() (*UnitAsset, error)
+ testCase string
+}
+
+func TestFilterByServiceDefAndDetails(t *testing.T) {
+ params := []filterByServDefAndDetailsParams{
+ {
+ true,
+ func() (ua *UnitAsset, err error) { return createRegistryWithServices(false) },
+ "Best case",
+ },
+ {
+ false,
+ func() (ua *UnitAsset, err error) { return createRegistryWithServices(true) },
+ "Bad case, key doesn't exist",
+ },
+ }
+
+ for _, c := range params {
+ ua, err := c.setup()
+ if err != nil {
+ t.Errorf("Failed during setup in '%s'", c.testCase)
+ }
+ checkLoc := map[string][]string{"Location": {"Livingroom"}}
+ lst := ua.FilterByServiceDefinitionAndDetails("testDef", checkLoc)
+ if (c.expectMatch == true) && (len(lst) < 1) {
+ t.Errorf("Expected atleast 1 service")
+ }
+ if (c.expectMatch == false) && (len(lst) > 0) {
+ t.Errorf("Expected no matches")
+ }
+ }
+}
+
+// ---------------------------------------------------- //
+// Help functions and structs to test checkExpiration()
+// ---------------------------------------------------- //
+
+func createRegistryWithService(year any) (ua *UnitAsset, cancel func(), err error) {
+ sys := createNewSys()
+ temp, cancel := newResource(createConfAssetMultipleTraits(), &sys)
+ ua, ok := temp.(*UnitAsset)
+ if !ok {
+ return nil, nil, fmt.Errorf("Failed while typecasting to local UnitAsset")
+ }
+
+ var test forms.ServiceRecord_v1
+ test.SystemName = "testSystem"
+ test.ProtoPort = map[string]int{"http": 1234}
+ test.IPAddresses = []string{"999.999.999.999"}
+ test.EndOfValidity = fmt.Sprintf("%v-01-02T15:04:05Z", year)
+ ua.serviceRegistry = map[int]forms.ServiceRecord_v1{0: test}
+ return ua, cancel, err
+}
+
+type checkExpirationParams struct {
+ servicePresent bool
+ setup func() (*UnitAsset, func(), error)
+ testCase string
+}
+
+func TestCheckExpiration(t *testing.T) {
+ params := []checkExpirationParams{
+ {
+ true,
+ func() (ua *UnitAsset, cancel func(), err error) { return createRegistryWithService(2026) },
+ "Best case, service not past expiration",
+ },
+ {
+ false,
+ func() (ua *UnitAsset, cancel func(), err error) { return createRegistryWithService(2006) },
+ "Bad case, service past expiration",
+ },
+ {
+ true,
+ func() (ua *UnitAsset, cancel func(), err error) { return createRegistryWithService("faulty") },
+ "Bad case, time parsing problem",
+ },
+ }
+ for _, c := range params {
+ ua, cancel, err := c.setup()
+ if err != nil {
+ t.Errorf("failed during setup: %v", err)
+ }
+
+ checkExpiration(ua, 0)
+ if _, exists := ua.serviceRegistry[0]; (exists == false) && (c.servicePresent == true) {
+ t.Errorf("expected the service to be present in '%s'", c.testCase)
+ }
+ if _, exists := ua.serviceRegistry[0]; (exists == true) && (c.servicePresent == false) {
+ t.Errorf("expected service to be removed in '%s'", c.testCase)
+ }
+
+ cancel()
+ }
+}
+
+// ----------------------------------------------------- //
+// Help functions and structs to test getUniqueSystems()
+// ----------------------------------------------------- //
+
+func createServRegistryHttp() (ua *UnitAsset, err error) {
+ initTemp := initTemplate()
+ ua, ok := initTemp.(*UnitAsset)
+ if !ok {
+ return nil, fmt.Errorf("Failed while typecasting to local UnitAsset")
+ }
+
+ var test forms.ServiceRecord_v1
+ test.SystemName = "testSystem"
+ test.ProtoPort = map[string]int{"http": 1234}
+ test.IPAddresses = []string{"999.999.999.999"}
+ ua.serviceRegistry = map[int]forms.ServiceRecord_v1{0: test}
+
+ return ua, nil
+}
+
+func createServRegistryHttps() (ua *UnitAsset, err error) {
+ initTemp := initTemplate()
+ ua, ok := initTemp.(*UnitAsset)
+ if !ok {
+ return nil, fmt.Errorf("Failed while typecasting to local UnitAsset")
+ }
+
+ var test forms.ServiceRecord_v1
+ test.SystemName = "testSystem"
+ test.ProtoPort = map[string]int{"https": 4321}
+ test.IPAddresses = []string{"888.888.888.888"}
+ ua.serviceRegistry = map[int]forms.ServiceRecord_v1{0: test}
+
+ return ua, nil
+}
+
+func createBrokenServRegistry() (ua *UnitAsset, err error) {
+ initTemp := initTemplate()
+ ua, ok := initTemp.(*UnitAsset)
+ if !ok {
+ return nil, fmt.Errorf("Failed while typecasting to local UnitAsset")
+ }
+
+ var test forms.ServiceRecord_v1
+ test.SystemName = "testSystem"
+ test.ProtoPort = map[string]int{"https": 0}
+ test.IPAddresses = []string{"888.888.888.888"}
+ ua.serviceRegistry = map[int]forms.ServiceRecord_v1{0: test}
+ return ua, nil
+}
+
+type getUniqueSystemsParams struct {
+ expectError bool
+ setup func() (ua *UnitAsset, err error)
+ testCase string
+}
+
+func TestGetUniqueSystems(t *testing.T) {
+ params := []getUniqueSystemsParams{
+ {
+ false,
+ func() (ua *UnitAsset, err error) { return createServRegistryHttp() },
+ "Best case, http",
+ },
+ {
+ false,
+ func() (ua *UnitAsset, err error) { return createServRegistryHttps() },
+ "Best case, https",
+ },
+ {
+ false,
+ func() (ua *UnitAsset, err error) { return createBrokenServRegistry() },
+ "Bad case, http/https not found",
+ },
+ }
+
+ for _, c := range params {
+ ua, err := c.setup()
+ if err != nil {
+ t.Errorf("Failed during setup in '%s' with error: %v", c.testCase, err)
+ }
+ _, err = getUniqueSystems(ua)
+ if c.expectError == false && err != nil {
+ t.Errorf("Failed while getting unique systems in '%s': %v", c.testCase, err)
+ }
+ if c.expectError == true && err == nil {
+ t.Errorf("Expected errors in '%s'", c.testCase)
+ }
+ }
+}
diff --git a/filmer/README.md b/filmer/README.md
new file mode 100644
index 0000000..bb149ad
--- /dev/null
+++ b/filmer/README.md
@@ -0,0 +1,29 @@
+# # mbaigo System: Filmer
+
+The filmer system takes live streams using connected camera as a service.
+The word filmer does not appear in the English dictionary.
+The get the stream service stores the file locally and provides a link to get the file.
+
+## Compiling
+To compile the code, one needs to initialize the *go.mod* file with ``` go mod init github.com/sdoque/filmer``` before running *go mod tidy*.
+
+The reason the *go.mod* file is not included in the repository is that when developing the mbaigo module, a replace statement needs to be included to point to the development code.
+
+To run the code, one just needs to type in ```go run .``` within a terminal or at a command prompt.
+
+It is **important** to start the program from within it own directory (and each system should have their own directory) because it looks for its configuration file there. If it does not find it there, it will generate one and shutdown to allow the configuration file to be updated.
+
+The configuration and operation of the system can be verified using the system's web server using a standard web browser, whose address is provided by the system at startup.
+
+To build the software for one's own machine,
+```go build -o parallax_imac```, where the ending is used to clarify for which platform the code is for.
+
+
+## Cross compiling/building
+The following commands enable one to build for different platforms:
+- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o filmer_rpi64```
+
+One can find a complete list of platform by typing *go tool dist list* at the command prompt
+
+If one wants to secure copy it to a Raspberry pi,
+`scp filmer_rpi64 jan@192.168.1.195:rpiExec/filmer/` where user is the *username* @ the *IP address* of the Raspberry Pi with a relative (to the user's home directory) target *rpiExec/filmer/* directory.filmer
\ No newline at end of file
diff --git a/filmer/filmer.go b/filmer/filmer.go
new file mode 100644
index 0000000..c2d080c
--- /dev/null
+++ b/filmer/filmer.go
@@ -0,0 +1,103 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Synecdoque
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The software is licensed under the MIT License. See the LICENSE file in this repository for details.
+ *
+ * Contributors:
+ * Jan A. van Deventer, Luleå - initial implementation
+ * Thomas Hedeler, Hamburg - initial implementation
+ ***************************************************************************SDG*/
+
+package main
+
+import (
+ "context"
+ "crypto/x509/pkix"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+func main() {
+ // prepare for graceful shutdown
+ ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled
+ defer cancel()
+
+ // instantiate the System
+ sys := components.NewSystem("filmer", ctx)
+
+ // instatiate the husk
+ sys.Husk = &components.Husk{
+ Description: " takes a picture using a camera and saves a file",
+ Details: map[string][]string{"Developer": {"Arrowhead"}},
+ ProtoPort: map[string]int{"https": 0, "http": 20162, "coap": 0},
+ InfoLink: "https://github.com/sdoque/mbaigo/tree/master/filmer",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
+ }
+
+ // instantiate a template unit asset
+ assetTemplate := initTemplate()
+ assetName := assetTemplate.GetName()
+ sys.UAssets[assetName] = &assetTemplate
+
+ // Configure the system
+ rawResources, err := usecases.Configure(&sys)
+ if err != nil {
+ log.Fatalf("Configuration error: %v\n", err)
+ }
+ sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
+ for _, raw := range rawResources {
+ var uac usecases.ConfigurableAsset
+ if err := json.Unmarshal(raw, &uac); err != nil {
+ log.Fatalf("Resource configuration error: %+v\n", err)
+ }
+ ua, cleanup := newResource(uac, &sys)
+ defer cleanup()
+ sys.UAssets[ua.GetName()] = &ua
+ }
+
+ // Generate PKI keys and CSR to obtain a authentication certificate from the CA
+ usecases.RequestCertificate(&sys)
+
+ // Register the (system) and its services
+ usecases.RegisterServices(&sys)
+
+ // start the requests handlers and servers
+ go usecases.SetoutServers(&sys)
+
+ // wait for shutdown signal, and gracefully close properly goroutines with context
+ <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal
+ fmt.Println("\nshuting down system", sys.Name)
+ cancel() // cancel the context, signaling the goroutines to stop
+ time.Sleep(3 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end
+}
+
+// Serving handles the resources services. NOTE: it expects those names from the request URL path
+func (ua *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath string) {
+ switch servicePath {
+ case "start":
+ fmt.Fprintln(w, ua.StartStreamURL())
+ case "stream":
+ ua.StreamTo(w, r)
+ default:
+ http.Error(w, "Invalid service request", http.StatusBadRequest)
+ }
+}
diff --git a/filmer/thing.go b/filmer/thing.go
new file mode 100644
index 0000000..fe4f18c
--- /dev/null
+++ b/filmer/thing.go
@@ -0,0 +1,203 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Synecdoque
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The software is licensed under the MIT License. See the LICENSE file in this repository for details.
+ *
+ * Contributors:
+ * Jan A. van Deventer, Luleå - initial implementation
+ * Thomas Hedeler, Hamburg - initial implementation
+ ***************************************************************************SDG*/
+
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "os/exec"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+// -------------------------------------Define the unit asset
+// Traits are Asset-specific configurable parameters and variables
+type Traits struct {
+}
+
+// UnitAsset type models the unit asset (interface) of the system
+type UnitAsset struct {
+ Name string `json:"name"`
+ Owner *components.System `json:"-"`
+ Details map[string][]string `json:"details"`
+ ServicesMap components.Services `json:"-"`
+ CervicesMap components.Cervices `json:"-"`
+ Traits
+}
+
+// GetName returns the name of the Resource.
+func (ua *UnitAsset) GetName() string {
+ return ua.Name
+}
+
+// GetServices returns the services of the Resource.
+func (ua *UnitAsset) GetServices() components.Services {
+ return ua.ServicesMap
+}
+
+// GetCervices returns the list of consumed services by the Resource.
+func (ua *UnitAsset) GetCervices() components.Cervices {
+ return ua.CervicesMap
+}
+
+// GetDetails returns the details of the Resource.
+func (ua *UnitAsset) GetDetails() map[string][]string {
+ return ua.Details
+}
+
+// GetTraits returns the traits of the Resource.
+func (ua *UnitAsset) GetTraits() any {
+ return ua.Traits
+}
+
+// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation)
+var _ components.UnitAsset = (*UnitAsset)(nil)
+
+//-------------------------------------Instantiate a unit asset template
+
+// initTemplate initializes a UnitAsset with default values.
+func initTemplate() components.UnitAsset {
+ // Define the services that expose the capabilities of the unit asset(s)
+ stream := components.Service{
+ Definition: "stream",
+ SubPath: "start",
+ Details: map[string][]string{"Forms": {"mpeg"}},
+ Description: " provides a video stream from the camera",
+ }
+
+ // var uat components.UnitAsset // this is an interface, which we then initialize
+ uat := &UnitAsset{
+ Name: "PiCam",
+ Details: map[string][]string{"Model": {"PiCam v3 NoIR"}},
+ ServicesMap: components.Services{
+ stream.SubPath: &stream, // Inline assignment of the temperature service
+ },
+ }
+ return uat
+}
+
+//-------------------------------------Instantiate the unit assets based on configuration
+
+// newResource creates the Resource resource with its pointers and channels based on the configuration
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
+ ua := &UnitAsset{ // this a struct that implements the UnitAsset interface
+ Name: configuredAsset.Name,
+ Owner: sys,
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
+ }
+ traits, err := UnmarshalTraits(configuredAsset.Traits)
+ if err != nil {
+ log.Println("Warning: could not unmarshal traits:", err)
+ } else if len(traits) > 0 {
+ ua.Traits = traits[0] // or handle multiple traits if needed
+ }
+
+ return ua, func() {
+ log.Println("disconnecting from sensors")
+ }
+}
+
+// UnmarshalTraits unmarshals a slice of json.RawMessage into a slice of Traits.
+func UnmarshalTraits(rawTraits []json.RawMessage) ([]Traits, error) {
+ var traitsList []Traits
+ for _, raw := range rawTraits {
+ var t Traits
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal trait: %w", err)
+ }
+ traitsList = append(traitsList, t)
+ }
+ return traitsList, nil
+}
+
+//-------------------------------------Unit asset's resource functions
+
+// StartStreamURL returns the URL to start the video stream.
+func (ua *UnitAsset) StartStreamURL() string {
+ ip := ua.Owner.Host.IPAddresses[0]
+ port := ua.Owner.Husk.ProtoPort["http"]
+ return fmt.Sprintf("http://%s:%d/filmer/%s/stream", ip, port, ua.Name)
+}
+
+// StreamTo streams the video from the camera to the HTTP response writer.
+// It uses libcamera-vid to capture video and sends it as a multipart MJPEG stream.
+func (ua *UnitAsset) StreamTo(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+
+ cmd := exec.Command("libcamera-vid",
+ "-t", "0",
+ "--codec", "mjpeg",
+ "--width", "640",
+ "--height", "480",
+ "--framerate", "15",
+ "-o", "-")
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ http.Error(w, "failed to create pipe: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if err := cmd.Start(); err != nil {
+ http.Error(w, "failed to start libcamera-vid: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ go func() {
+ <-r.Context().Done()
+ if cmd.Process != nil {
+ _ = cmd.Process.Kill()
+ }
+ }()
+
+ buffer := make([]byte, 0)
+ temp := make([]byte, 4096)
+
+ for {
+ n, err := stdout.Read(temp)
+ if err != nil {
+ log.Println("stream read error:", err)
+ break
+ }
+ buffer = append(buffer, temp[:n]...)
+
+ for {
+ start := bytes.Index(buffer, []byte{0xFF, 0xD8}) // JPEG SOI
+ end := bytes.Index(buffer, []byte{0xFF, 0xD9}) // JPEG EOI
+ if start >= 0 && end > start {
+ frame := buffer[start : end+2]
+ buffer = buffer[end+2:]
+
+ fmt.Fprintf(w, "--frame\r\n")
+ fmt.Fprintf(w, "Content-Type: image/jpeg\r\n")
+ fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(frame))
+ w.Write(frame)
+
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush() // very important!
+ }
+ } else {
+ break
+ }
+ }
+ }
+}
diff --git a/kgrapher/README.md b/kgrapher/README.md
index 546899a..5522e8a 100644
--- a/kgrapher/README.md
+++ b/kgrapher/README.md
@@ -10,10 +10,13 @@ Currently, when the KGrapher’s only service is invoked from a web browser, it
Using the model in conjunction with the Arrowhead Framework Ontology (afo), a computer can infer new knowledge about the local cloud and reason about it.
+The KGrapher also serves (makes available) local ontologies and knowledge graphs.
+One can obtain these description through the *localOntologies* service.
+
## Compiling
To compile the code, one needs initialize the *go.mod* file with ``` go mod init github.com/sdoque/systems/kgrapher``` before running *go mod tidy*.
-To run the code, one just needs to type in ```go run kgrapher.go thing.go``` within a terminal or at a command prompt. One can also build it to get an executable of it ```go run modeler.go thing.go```
+To run the code, one just needs to type in ```go run .``` within a terminal or at a command prompt.
It is **important** to start the program from within its own directory (and each system should have their own directory) because it looks for its configuration file there. If it does not find it there, it will generate one and shutdown to allow the configuration file to be updated.
@@ -26,7 +29,7 @@ To build the software for one's own machine,
## Cross compiling/building
The following commands enable one to build for different platforms:
-- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o kgrapher_rpi64 kgrapher.go thing.go```
+- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o kgrapher_rpi64```
One can find a complete list of platform by typing *go tool dist list* at the command prompt
diff --git a/kgrapher/kgrapher.go b/kgrapher/kgrapher.go
index 0621033..f209bbe 100644
--- a/kgrapher/kgrapher.go
+++ b/kgrapher/kgrapher.go
@@ -18,6 +18,7 @@ package main
import (
"context"
+ "crypto/x509/pkix"
"encoding/json"
"fmt"
"log"
@@ -43,6 +44,14 @@ func main() {
Details: map[string][]string{"Developer": {"Synecdoque"}},
ProtoPort: map[string]int{"https": 0, "http": 20105, "coap": 0},
InfoLink: "https://github.com/sdoque/systems/tree/main/kgrapher",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
}
// instantiate a template unit asset
@@ -51,17 +60,17 @@ func main() {
sys.UAssets[assetName] = &assetTemplate
// Configure the system
- rawResources, servsTemp, err := usecases.Configure(&sys)
+ rawResources, err := usecases.Configure(&sys)
if err != nil {
log.Fatalf("Configuration error: %v\n", err)
}
sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
for _, raw := range rawResources {
- var uac UnitAsset
+ var uac usecases.ConfigurableAsset
if err := json.Unmarshal(raw, &uac); err != nil {
log.Fatalf("Resource configuration error: %+v\n", err)
}
- ua, cleanup := newResource(uac, &sys, servsTemp)
+ ua, cleanup := newResource(uac, &sys)
defer cleanup()
sys.UAssets[ua.GetName()] = &ua
}
@@ -87,11 +96,16 @@ func (ua *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath
switch servicePath {
case "cloudgraph":
ua.aggregate(w, r)
+ case "localontologies":
+ ua.listOntologies(w, r)
+ case "files":
+ // this is a catch-all for the files service, which is not implemented in this system
default:
http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest)
}
}
+// assembleOntologies writes out the knowledge graph of the local cloud and pushes it to GraphDB
func (ua *UnitAsset) aggregate(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "GET":
@@ -100,3 +114,16 @@ func (ua *UnitAsset) aggregate(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Method is not supported.", http.StatusNotFound)
}
}
+
+// listOntologies writes out the HTML produced by localOntologies()
+func (ua *UnitAsset) listOntologies(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ p := r.Pattern
+ html := ua.localOntologies(p)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8") // set the content type for the response of the HTML page not the ontologies
+ fmt.Fprint(w, html)
+ default:
+ http.Error(w, "Method is not supported.", http.StatusNotFound)
+ }
+}
diff --git a/kgrapher/thing.go b/kgrapher/thing.go
index 34b1ba0..5aceb0f 100644
--- a/kgrapher/thing.go
+++ b/kgrapher/thing.go
@@ -19,11 +19,14 @@ package main
import (
"bytes"
"context"
+ "encoding/json"
"fmt"
"io"
"log"
"mime"
"net/http"
+ "os"
+ "path/filepath"
"strings"
"time"
@@ -32,7 +35,13 @@ import (
"github.com/sdoque/mbaigo/usecases"
)
-//-------------------------------------Define the unit asset
+// -------------------------------------Define the unit asset
+// Traits are Asset-specific configurable parameters
+type Traits struct {
+ SystemList forms.SystemRecordList_v1 `json:"-"`
+ RepositoryURL string `json:"graphDBurl"`
+ LOntologies map[string]string `json:"localOntologies"` // map of ontology names to their file paths
+}
// UnitAsset type models the unit asset (interface) of the system
type UnitAsset struct {
@@ -41,9 +50,8 @@ type UnitAsset struct {
Details map[string][]string `json:"details"`
ServicesMap components.Services `json:"-"`
CervicesMap components.Cervices `json:"-"`
- //
- SystemList forms.SystemRecordList_v1 `json:"-"`
- RepositoryURL string `json:"graphDBurl"`
+ // Asset-specific parameters
+ Traits
}
// GetName returns the name of the Resource.
@@ -66,6 +74,11 @@ func (ua *UnitAsset) GetDetails() map[string][]string {
return ua.Details
}
+// GetTraits returns the traits of the Resource.
+func (ua *UnitAsset) GetTraits() any {
+ return ua.Traits
+}
+
// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation)
var _ components.UnitAsset = (*UnitAsset)(nil)
@@ -82,13 +95,26 @@ func initTemplate() components.UnitAsset {
Description: "provides the knowledge graph of a local cloud (GET)",
}
+ localOntologies := components.Service{
+ Definition: "localOntologies",
+ SubPath: "localontologies",
+ Details: map[string][]string{"Location": {"LocalCloud"}},
+ RegPeriod: 61,
+ Description: "provides the list of local ontologies (GET)",
+ }
+
// var uat components.UnitAsset // this is an interface, which we then initialize
uat := &UnitAsset{
- Name: "assembler",
- Owner: &components.System{},
- Details: map[string][]string{"Location": {"LocalCloud"}},
- ServicesMap: map[string]*components.Service{cloudgraph.SubPath: &cloudgraph},
- RepositoryURL: "http://localhost:7200/repositories/Arrowhead/statements",
+ Name: "assembler",
+ Owner: &components.System{},
+ Details: map[string][]string{"Location": {"LocalCloud"}},
+ ServicesMap: map[string]*components.Service{cloudgraph.SubPath: &cloudgraph, localOntologies.SubPath: &localOntologies},
+ Traits: Traits{
+ RepositoryURL: "http://localhost:7200/repositories/Arrowhead/statements",
+ LOntologies: map[string]string{
+ "alc": "alc-ontology-local.ttl", // Initialize the map for local ontologies
+ },
+ },
}
return uat
}
@@ -96,57 +122,82 @@ func initTemplate() components.UnitAsset {
//-------------------------------------Instantiate unit asset(s) based on configuration
// newResource creates the unit asset with its pointers and channels based on the configuration
-func newResource(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) {
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
// var ua components.UnitAsset // this is an interface, which we then initialize
ua := &UnitAsset{ // this is an interface, which we then initialize
- Name: uac.Name,
- Owner: sys,
- Details: uac.Details,
- ServicesMap: components.CloneServices(servs),
- RepositoryURL: uac.RepositoryURL,
+ Name: configuredAsset.Name,
+ Owner: sys,
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
+ }
+
+ traits, err := UnmarshalTraits(configuredAsset.Traits)
+ if err != nil {
+ log.Println("Warning: could not unmarshal traits:", err)
+ } else if len(traits) > 0 {
+ ua.Traits = traits[0] // or handle multiple traits if needed
}
- // start the unit asset(s)
+ // Ensure that you have a valid local ontology directory
+ const dir = "./files"
+ // 1. Ensure ./files exists
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ log.Fatalf("could not create directory %q: %v", dir, err)
+ }
+ serverAddress := ua.Owner.Host.IPAddresses[0] // Use the first IP address of the system
+ ontologyURL := fmt.Sprintf("http://%s:20105/kgrapher/assembler/files/", serverAddress) //only using http for now TODO: use https
+ // 2. Resolve local ontologies to their full URLs
+ resolveLocalOntologies(ua.LOntologies, dir, ontologyURL)
return ua, func() {
log.Println("Disconnecting from GraphDB")
}
}
+// UnmarshalTraits un-marshals a slice of json.RawMessage into a slice of Traits.
+func UnmarshalTraits(rawTraits []json.RawMessage) ([]Traits, error) {
+ var traitsList []Traits
+ for _, raw := range rawTraits {
+ var t Traits
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal trait: %w", err)
+ }
+ traitsList = append(traitsList, t)
+ }
+ return traitsList, nil
+}
+
+// resolveLocalOntologies checks if the local ontology files exist in the specified directory.
+// If they do, it updates the map with the full URL; if not, it removes the entry and logs a warning.
+func resolveLocalOntologies(localOntologies map[string]string, dir string, baseURL string) {
+ for prefix, filename := range localOntologies {
+ fullPath := filepath.Join(dir, filename)
+
+ if _, err := os.Stat(fullPath); err == nil {
+ // File exists: update to full URL
+ localOntologies[prefix] = baseURL + filename
+ } else {
+ // File does not exist: remove entry and warn
+ fmt.Printf("Warning: ontology file %s not found in %s. Removing prefix '%s'.\n", filename, dir, prefix)
+ delete(localOntologies, prefix)
+ }
+ }
+}
+
// -------------------------------------Unit asset's function methods
// assembles ontologies gets the list of systems from the lead registrar and then the ontology of each system
func (ua *UnitAsset) assembleOntologies(w http.ResponseWriter) {
// Look for leading service registrar
- var leadingRegistrar *components.CoreSystem
- for _, cSys := range ua.Owner.CoreS {
- core := cSys
- if core.Name == "serviceregistrar" {
- resp, err := http.Get(core.Url + "/status")
- if err != nil {
- fmt.Println("Error checking service registrar status:", err)
- continue
- }
- bodyBytes, err := io.ReadAll(resp.Body)
- resp.Body.Close()
- if err != nil {
- fmt.Println("Error reading service registrar response body:", err)
- continue
- }
- if strings.HasPrefix(string(bodyBytes), "lead Service Registrar since") {
- leadingRegistrar = core
- }
- }
- }
- if leadingRegistrar == nil {
- fmt.Printf("no service registrar found\n")
- http.Error(w, "Internal Server Error: no service registrar found", http.StatusInternalServerError)
+ leadingRegistrarURL, err := components.GetRunningCoreSystemURL(ua.Owner, "serviceregistrar")
+ if err != nil {
+ log.Printf("Error getting the leading service registrar URL: %s\n", err)
+ http.Error(w, "Internal Server Error: unable to get leading service registrar URL", http.StatusInternalServerError)
return
}
-
- // request list of systems in the cloud
- leadUrl := leadingRegistrar.Url + "/syslist"
+ // request list of systems in the cloud from the leading service registrar
+ leadUrl := leadingRegistrarURL + "/syslist"
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -189,7 +240,7 @@ func (ua *UnitAsset) assembleOntologies(w http.ResponseWriter) {
return
}
- // Prepare the local cloud's knowledge graph by asking each system their their knowledeg graph
+ // Prepare the local cloud's knowledge graph by asking each system their their knowledge graph
prefixes := make(map[string]bool) // To store unique prefixes
processedBlocks := make(map[string]bool) // To track processed RDF blocks
var uniqueIndividuals []string // To store unique RDF individuals
@@ -239,14 +290,19 @@ func (ua *UnitAsset) assembleOntologies(w http.ResponseWriter) {
// Construct the graph string
var graph string
+ // updatePrefixes(prefixes, ua.Traits.LOntologies) //update prefixes with local ontology URIs TO DO: remove function call, it is not used anymore
// Write unique prefixes once
for prefix := range prefixes {
graph += prefix + "\n"
}
// Add the ontology definition
- rdf := "\n:ontology a owl:Ontology .\n"
- graph += rdf + "\n"
+ ontoImport := "\n:ontology a owl:Ontology "
+ for _, uri := range ua.Traits.LOntologies {
+ ontoImport += fmt.Sprintf(";\n owl:imports <%s> ", uri)
+ }
+ ontoImport += ".\n"
+ graph += ontoImport + "\n"
// Write unique RDF blocks
for _, block := range uniqueIndividuals {
@@ -284,3 +340,62 @@ func (ua *UnitAsset) assembleOntologies(w http.ResponseWriter) {
fmt.Println("GraphDB Response Body:", string(body))
}
}
+
+// updatePrefix_Target updates the prefixes in the RDF blocks with the new URIs from the local ontologies.
+func updatePrefixes(prefixes map[string]bool, prefixUpdates map[string]string) {
+ updated := make(map[string]bool)
+
+ for line := range prefixes {
+ if strings.HasPrefix(line, "@prefix") {
+ parts := strings.Fields(line)
+ if len(parts) >= 3 {
+ prefix := strings.TrimSuffix(parts[1], ":") // e.g., "alc"
+ if newURI, ok := prefixUpdates[prefix]; ok {
+ // Update the line with the new URI
+ line = fmt.Sprintf("@prefix %s: <%s#> .", prefix, newURI)
+ }
+ }
+ }
+ updated[line] = true
+ }
+
+ // Replace the original map with the updated one
+ for k := range prefixes {
+ delete(prefixes, k)
+ }
+ for k := range updated {
+ prefixes[k] = true
+ }
+}
+
+// ----------- Local Ontologies Service -----------------------------------------------------------
+
+// localOntologiesHandler handles requests to the /localontologies endpoint
+// localOntologies reads the ./ontologies directory and builds an HTML list
+func (ua *UnitAsset) localOntologies(sp string) string {
+ entries, err := os.ReadDir("./files")
+ if err != nil {
+ return fmt.Sprintf("Error: could not read files directory: %v
", err)
+ }
+
+ var sb strings.Builder
+ sb.WriteString(`
+Available Ontologies
+
+Available Ontologies
+`)
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ // Foo.owl will now hit your FileServer at "/" and serve ./files/Foo.owl
+ link := sp + ua.Name + "/files/" + name
+ sb.WriteString(fmt.Sprintf(`- %s
`, link, name))
+ }
+
+ sb.WriteString(`
+`)
+ return sb.String()
+}
diff --git a/leveler/README.md b/leveler/README.md
new file mode 100644
index 0000000..67de751
--- /dev/null
+++ b/leveler/README.md
@@ -0,0 +1,37 @@
+# mbaigo System: Leveler
+
+The Leveler is a distributed control system (DSC) for a pump station (part of an automatic control course's lab) where the aim is to keep the level of an upper tank constant, where the tank is part of a closed (water) circuit.
+
+It offers three services, *setpoint*, *levelError* and *jitter*.
+The setpoint can be read (e.g., GET) or set (e.g., PUT).
+The error signal is the difference between the setpoint or desired temperature and the current temperature.
+It can only be read.
+The jitter is the time it takes to obtain a new temperature reading and setting the new valve position.
+
+The control loop is executed every 5 seconds, and can be configured.
+
+## Compiling
+The module needs to be initialize in the terminal or command line interface with the command *go.mod* file with ``` go mod init github.com/sdoque/systems/leveler``` before running *go mod tidy*.
+
+To run the code, one just needs to type in ```go run .``` within a terminal or at a command prompt.
+
+It is **important** to start the program from within its own directory (and each system should have their own directory) because it looks for its configuration file there. If it does not find it there, it will generate one and shutdown to allow the configuration file to be updated.
+
+The configuration and operation of the system can be verified using the system's web server using a standard web browser, whose address is provided by the system at startup.
+
+To build the software for one's own machine,
+```go build -o leveler_imac```, where the ending is used to clarify for which platform the code has been compiled for.
+
+
+## Cross compiling/building
+The following commands enable one to build for different platforms:
+- Intel Mac: ```GOOS=darwin GOARCH=amd64 go build -o leveler_imac ```
+- ARM Mac: ```GOOS=darwin GOARCH=arm64 go build -o leveler_amac```
+- Windows 64: ```GOOS=windows GOARCH=amd64 go build -o leveler.exe ```
+- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o leveler_rpi64```
+- Linux: ```GOOS=linux GOARCH=amd64 go build -o leveler_linux ```
+
+One can find a complete list of platform by typing *go tool dist list* at the command prompt
+
+If one wants to secure copy it to a Raspberry pi,
+`scp leveler_rpi64 pi@192.168.1.9:station/leveler/` where user is the *pi* @ the *IP address* of the Raspberry Pi with a relative (to the user's home directory) target *station/leveler/* directory.
\ No newline at end of file
diff --git a/leveler/leveler.go b/leveler/leveler.go
new file mode 100644
index 0000000..50ae1d9
--- /dev/null
+++ b/leveler/leveler.go
@@ -0,0 +1,141 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Synecdoque
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The software is licensed under the MIT License. See the LICENSE file in this repository for details.
+ *
+ * Contributors:
+ * Jan A. van Deventer, Luleå - initial implementation
+ * Thomas Hedeler, Hamburg - initial implementation
+ ***************************************************************************SDG*/
+
+package main
+
+import (
+ "context"
+ "crypto/x509/pkix"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+func main() {
+ // prepare for graceful shutdown
+ ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled
+ defer cancel() // make sure all paths cancel the context to avoid context leak
+
+ // instantiate the System
+ sys := components.NewSystem("Leveler", ctx)
+
+ // Instantiate the husk
+ sys.Husk = &components.Husk{
+ Description: " is a controller for a consumed servo motor position based on a consumed temperature",
+ Details: map[string][]string{"Developer": {"Synecdoque"}},
+ ProtoPort: map[string]int{"https": 0, "http": 20154, "coap": 0},
+ InfoLink: "https://github.com/sdoque/systems/tree/main/leveler",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
+ }
+
+ // instantiate a template unit asset
+ assetTemplate := initTemplate()
+ assetName := assetTemplate.GetName()
+ sys.UAssets[assetName] = &assetTemplate
+
+ // Configure the system
+ rawResources, err := usecases.Configure(&sys)
+ if err != nil {
+ log.Fatalf("Configuration error: %v\n", err)
+ }
+ sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
+ for _, raw := range rawResources {
+ var uac usecases.ConfigurableAsset
+ if err := json.Unmarshal(raw, &uac); err != nil {
+ log.Fatalf("Resource configuration error: %+v\n", err)
+ }
+ ua, cleanup := newResource(uac, &sys)
+ defer cleanup()
+ sys.UAssets[ua.GetName()] = &ua
+ }
+
+ // Generate PKI keys and CSR to obtain a authentication certificate from the CA
+ usecases.RequestCertificate(&sys)
+
+ // Register the (system) and its services
+ usecases.RegisterServices(&sys)
+
+ // start the http handler and server
+ go usecases.SetoutServers(&sys)
+
+ // wait for shutdown signal, and gracefully close properly goroutines with context
+ <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal
+ fmt.Println("\nshuting down system", sys.Name)
+ cancel() // cancel the context, signaling the goroutines to stop
+ time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end
+}
+
+// Serving handles the resources services. NOTE: it expects those names from the request URL path
+func (t *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath string) {
+ switch servicePath {
+ case "setpoint":
+ t.setpt(w, r)
+ case "levelerror":
+ t.diff(w, r)
+ case "jitter":
+ t.variations(w, r)
+ default:
+ http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest)
+ }
+}
+
+func (rsc *UnitAsset) setpt(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ setPointForm := rsc.getSetPoint()
+ usecases.HTTPProcessGetRequest(w, r, &setPointForm)
+ case "PUT":
+ sig, err := usecases.HTTPProcessSetRequest(w, r)
+ if err != nil {
+ log.Println("Error with the setting request of the position ", err)
+ }
+ rsc.setSetPoint(sig)
+ default:
+ http.Error(w, "Method is not supported.", http.StatusNotFound)
+ }
+}
+
+func (rsc *UnitAsset) diff(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ signalErr := rsc.getError()
+ usecases.HTTPProcessGetRequest(w, r, &signalErr)
+ default:
+ http.Error(w, "Method is not supported.", http.StatusNotFound)
+ }
+}
+
+func (rsc *UnitAsset) variations(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ signalErr := rsc.getJitter()
+ usecases.HTTPProcessGetRequest(w, r, &signalErr)
+ default:
+ http.Error(w, "Method is not supported.", http.StatusNotFound)
+ }
+}
diff --git a/leveler/thing.go b/leveler/thing.go
new file mode 100644
index 0000000..017133e
--- /dev/null
+++ b/leveler/thing.go
@@ -0,0 +1,318 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Synecdoque
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The software is licensed under the MIT License. See the LICENSE file in this repository for details.
+ *
+ * Contributors:
+ * Jan A. van Deventer, Luleå - initial implementation
+ * Thomas Hedeler, Hamburg - initial implementation
+ ***************************************************************************SDG*/
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "math"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+// -------------------------------------Define the unit asset
+// Traits are Asset-specific configurable parameters and variables
+type Traits struct {
+ SetPt float64 `json:"setPoint"` // the set point for the level
+ Period time.Duration `json:"samplingPeriod"`
+ Kp float64 `json:"kp"`
+ Lambda float64 `json:"lambda"`
+ Ki float64 `json:"ki"`
+ jitter time.Duration
+ deviation float64
+ integral float64
+ previousLevel float64 // previous level reading to avoid flooding the log
+}
+
+// UnitAsset type models the unit asset (interface) of the system
+type UnitAsset struct {
+ Name string `json:"name"`
+ Owner *components.System `json:"-"`
+ Details map[string][]string `json:"details"`
+ ServicesMap components.Services `json:"-"`
+ CervicesMap components.Cervices `json:"-"`
+ Traits
+}
+
+// GetName returns the name of the Resource.
+func (ua *UnitAsset) GetName() string {
+ return ua.Name
+}
+
+// GetServices returns the services of the Resource.
+func (ua *UnitAsset) GetServices() components.Services {
+ return ua.ServicesMap
+}
+
+// GetCervices returns the list of consumed services by the Resource.
+func (ua *UnitAsset) GetCervices() components.Cervices {
+ return ua.CervicesMap
+}
+
+// GetDetails returns the details of the Resource.
+func (ua *UnitAsset) GetDetails() map[string][]string {
+ return ua.Details
+}
+
+// GetTraits returns the traits of the Resource.
+func (ua *UnitAsset) GetTraits() any {
+ return ua.Traits
+}
+
+// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation)
+var _ components.UnitAsset = (*UnitAsset)(nil)
+
+//-------------------------------------Instantiate a unit asset template
+
+// initTemplate initializes a UnitAsset with default values.
+func initTemplate() components.UnitAsset {
+ setPointService := components.Service{
+ Definition: "setPoint",
+ SubPath: "setpoint",
+ Details: map[string][]string{"Unit": {"Percent"}, "Forms": {"SignalA_v1a"}},
+ RegPeriod: 100,
+ CUnit: "Eur/h",
+ Description: "provides the current thermal setpoint (GET) or sets it (PUT)",
+ }
+ levelErrorService := components.Service{
+ Definition: "levelError",
+ SubPath: "levelerror",
+ Details: map[string][]string{"Unit": {"Percent"}, "Forms": {"SignalA_v1a"}},
+ RegPeriod: 30,
+ Description: "provides the current difference between the set point and the temperature (GET)",
+ }
+ jitterService := components.Service{
+ Definition: "jitter",
+ SubPath: "jitter",
+ Details: map[string][]string{"Unit": {"millisecond"}, "Forms": {"SignalA_v1a"}},
+ RegPeriod: 120,
+ Description: "provides the current jitter or control algorithm execution calculated every period (GET)",
+ }
+
+ assetTraits := Traits{
+ SetPt: 20,
+ Period: 5,
+ Kp: 5,
+ Lambda: 0.5,
+ Ki: 0,
+ }
+
+ // create the unit asset template
+ uat := &UnitAsset{
+ Name: "Leveler_1",
+ Details: map[string][]string{"Location": {"UpperTank"}},
+ Traits: assetTraits,
+ ServicesMap: components.Services{
+ setPointService.SubPath: &setPointService,
+ levelErrorService.SubPath: &levelErrorService,
+ jitterService.SubPath: &jitterService,
+ },
+ }
+ return uat
+}
+
+//-------------------------------------Instantiate the unit assets based on configuration
+
+// newResource creates the Resource resource with its pointers and channels based on the configuration using the tConig structs
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
+ // determine the protocols that the system supports
+ sProtocols := components.SProtocols(sys.Husk.ProtoPort)
+ // instantiate the consumed services
+ t := &components.Cervice{
+ Definition: "level",
+ Protos: sProtocols,
+ Nodes: make(map[string][]string, 0),
+ }
+
+ r := &components.Cervice{
+ Definition: "pumpSpeed",
+ Protos: sProtocols,
+ Nodes: make(map[string][]string, 0),
+ }
+
+ // instantiate the unit asset
+ ua := &UnitAsset{
+ Name: configuredAsset.Name,
+ Owner: sys,
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
+ CervicesMap: components.Cervices{
+ t.Definition: t,
+ r.Definition: r,
+ },
+ }
+
+ traits, err := UnmarshalTraits(configuredAsset.Traits)
+ if err != nil {
+ log.Println("Warning: could not unmarshal traits:", err)
+ } else if len(traits) > 0 {
+ ua.Traits = traits[0] // or handle multiple traits if needed
+ }
+
+ ua.CervicesMap["level"].Details = components.MergeDetails(ua.Details, map[string][]string{"Unit": {"Percent"}, "Forms": {"SignalA_v1a"}, "Location": {"UpperTank"}})
+ ua.CervicesMap["pumpSpeed"].Details = components.MergeDetails(ua.Details, map[string][]string{"Unit": {"Percent"}, "Forms": {"SignalA_v1a"}})
+
+ // start the unit asset(s)
+ go ua.feedbackLoop(sys.Ctx)
+
+ return ua, func() {
+ log.Println("Shutting down thermostat ", ua.Name)
+ }
+}
+
+// UnmarshalTraits unmarshals a slice of json.RawMessage into a slice of Traits.
+func UnmarshalTraits(rawTraits []json.RawMessage) ([]Traits, error) {
+ var traitsList []Traits
+ for _, raw := range rawTraits {
+ var t Traits
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal trait: %w", err)
+ }
+ traitsList = append(traitsList, t)
+ }
+ return traitsList, nil
+}
+
+//-------------------------------------Thing's resource methods
+
+// getSetPoint fills out a signal form with the current level set point
+func (ua *UnitAsset) getSetPoint() (f forms.SignalA_v1a) {
+ f.NewForm()
+ f.Value = ua.SetPt
+ f.Unit = "Percent"
+ f.Timestamp = time.Now()
+ return f
+}
+
+// setSetPoint updates the level set point
+func (ua *UnitAsset) setSetPoint(f forms.SignalA_v1a) {
+ ua.SetPt = f.Value
+ log.Printf("new set point: %.1f", f.Value)
+}
+
+// getErrror fills out a signal form with the current thermal setpoint and temperature
+func (ua *UnitAsset) getError() (f forms.SignalA_v1a) {
+ f.NewForm()
+ f.Value = ua.deviation
+ f.Unit = "Percent"
+ f.Timestamp = time.Now()
+ return f
+}
+
+// getJitter fills out a signal form with the current jitter
+func (ua *UnitAsset) getJitter() (f forms.SignalA_v1a) {
+ f.NewForm()
+ f.Value = float64(ua.jitter.Milliseconds())
+ f.Unit = "millisecond"
+ f.Timestamp = time.Now()
+ return f
+}
+
+// feedbackLoop is THE control loop (IPR of the system)
+func (ua *UnitAsset) feedbackLoop(ctx context.Context) {
+ // Initialize a ticker for periodic execution
+ ticker := time.NewTicker(ua.Period * time.Second)
+ defer ticker.Stop()
+
+ // start the control loop
+ for {
+ select {
+ case <-ticker.C:
+ ua.processFeedbackLoop()
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+// processFeedbackLoop is called to execute the control process
+func (ua *UnitAsset) processFeedbackLoop() {
+ jitterStart := time.Now()
+
+ // get the current level
+ tf, err := usecases.GetState(ua.CervicesMap["level"], ua.Owner)
+ if err != nil {
+ log.Printf("\n unable to obtain a level reading error: %s\n", err)
+ return
+ }
+ // Perform a type assertion to convert the returned Form to SignalA_v1a
+ tup, ok := tf.(*forms.SignalA_v1a)
+ if !ok {
+ log.Println("problem unpacking the level signal form")
+ return
+ }
+
+ // perform the control algorithm
+ ua.deviation = ua.SetPt - tup.Value
+ output := ua.calculateOutput(ua.deviation)
+
+ // prepare the form to send
+ var of forms.SignalA_v1a
+ of.NewForm()
+ of.Value = output
+ of.Unit = ua.CervicesMap["pumpSpeed"].Details["Unit"][0]
+ of.Timestamp = time.Now()
+
+ // pack the new pumpSpeed state form
+ op, err := usecases.Pack(&of, "application/json")
+ if err != nil {
+ return
+ }
+ // send the new state request
+ _, err = usecases.SetState(ua.CervicesMap["pumpSpeed"], ua.Owner, op)
+ if err != nil {
+ log.Printf("cannot update pump speed: %s\n", err)
+ return
+ }
+
+ if tup.Value != ua.previousLevel {
+ log.Printf("the level is %.2f percent with an error %.2f percent and the pumpSpeed set at %.2f%%\n", tup.Value, ua.deviation, output)
+ ua.previousLevel = tup.Value
+ }
+
+ ua.jitter = time.Since(jitterStart)
+}
+
+// calculateOutput is the actual P controller
+func (ua *UnitAsset) calculateOutput(levelDiff float64) float64 {
+ // Proportional term
+ pTerm := ua.Kp * levelDiff
+
+ // Update integral with exponential decay using Lambda
+ sampleSeconds := (ua.Period * time.Second).Seconds()
+ decay := math.Exp(-sampleSeconds / ua.Lambda)
+ ua.integral = decay*ua.integral + levelDiff*sampleSeconds
+
+ // Integral term
+ iTerm := ua.Ki * ua.integral
+
+ // Combined PI output
+ output := pTerm + iTerm
+
+ // Limit output to [0, 100]%
+ if output < 0 {
+ output = 0
+ } else if output > 100 {
+ output = 100
+ }
+ return output
+}
diff --git a/messenger/dashboard.html b/messenger/dashboard.html
new file mode 100644
index 0000000..d504da8
--- /dev/null
+++ b/messenger/dashboard.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+Dashboard
+
+
+
+
+
+
+Errors
+
+{{range .Errors}}
+ - {{.}}
+{{else}}
+ - No errors.
+{{end}}
+
+
+
+Warnings
+
+{{range .Warnings}}
+ - {{.}}
+{{else}}
+ - No warnings.
+{{end}}
+
+
+
+Log
+
+{{range .Latest}}
+ - {{.}}
+{{else}}
+ - No logs.
+{{end}}
+
+
+
+
+
+
diff --git a/messenger/go.mod b/messenger/go.mod
new file mode 100644
index 0000000..392f772
--- /dev/null
+++ b/messenger/go.mod
@@ -0,0 +1,8 @@
+module github.com/sdoque/systems/messenger
+
+go 1.24.4
+
+require github.com/sdoque/mbaigo v0.0.0-20250520155324-7390c339652a
+
+// Replaces this library with a patched version
+replace github.com/sdoque/mbaigo v0.0.0-20250520155324-7390c339652a => /home/lmas/code/mbaigo
diff --git a/messenger/go.sum b/messenger/go.sum
new file mode 100644
index 0000000..e69de29
diff --git a/messenger/messenger.go b/messenger/messenger.go
new file mode 100644
index 0000000..5de9a29
--- /dev/null
+++ b/messenger/messenger.go
@@ -0,0 +1,152 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/x509/pkix"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+func main() {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ sys := components.NewSystem("messenger", ctx)
+ sys.Husk = &components.Husk{
+ Description: "is a logging system that recieves log messages from other systems.",
+ Details: map[string][]string{"Developer": {"alex"}},
+ ProtoPort: map[string]int{"https": 0, "http": 20106, "coap": 0},
+ InfoLink: "https://github.com/sdoque/systems/tree/main/messenger",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"alex"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
+ }
+
+ assetTemplate := initTemplate()
+ sys.UAssets[assetTemplate.GetName()] = &assetTemplate
+ rawResources, err := usecases.Configure(&sys)
+ if err != nil {
+ usecases.LogWarn(&sys, "configuration error: %v", err)
+ return
+ }
+
+ sys.UAssets = make(map[string]*components.UnitAsset)
+ for _, raw := range rawResources {
+ var uac usecases.ConfigurableAsset
+ if err := json.Unmarshal(raw, &uac); err != nil {
+ usecases.LogError(&sys, "resource configuration error: %+v", err)
+ return
+ }
+ ua, cleanup, err := newResource(uac, &sys)
+ if err != nil {
+ usecases.LogError(&sys, "new resource: %v", err)
+ return
+ }
+ defer cleanup()
+ sys.UAssets[ua.GetName()] = &ua
+ }
+
+ usecases.RequestCertificate(&sys)
+ usecases.RegisterServices(&sys)
+ go usecases.SetoutServers(&sys)
+ <-sys.Sigs
+ usecases.LogInfo(&sys, "shutting down %s", sys.Name)
+ cancel()
+ time.Sleep(2 * time.Second)
+}
+
+func (ua *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath string) {
+ switch servicePath {
+ case "message":
+ ua.handleNewMessage(w, r)
+ case "dashboard":
+ ua.handleDashboard(w, r)
+ default:
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ }
+}
+
+func (ua *UnitAsset) handleNewMessage(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError),
+ http.StatusInternalServerError)
+ return
+ }
+ defer r.Body.Close()
+
+ form, err := usecases.Unpack(body, r.Header.Get("Content-Type"))
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+ msg, ok := form.(*forms.SystemMessage_v1)
+ if !ok {
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+ ua.addMessage(*msg) // Don't want to have to deal with pointers, hence the *
+}
+
+// Encapsulates the regular bytes.Buffer, in order to allow causing mock errors
+type mockableBuffer struct {
+ bytes.Buffer // This embedded struct is available as "mockableBuffer.Buffer" by default
+ errWrite error
+}
+
+func (mock *mockableBuffer) setWriteError(err error) {
+ mock.errWrite = err
+}
+
+func (mock *mockableBuffer) Write(body []byte) (int, error) {
+ if mock.errWrite != nil {
+ return 0, mock.errWrite
+ }
+ return mock.Buffer.Write(body)
+}
+
+const testBufferHeader string = "x-testing-buffer"
+
+func (ua *UnitAsset) handleDashboard(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+ errors, warnings, latest := ua.filterLogs()
+ data := map[string]any{
+ "Errors": errors,
+ "Warnings": warnings,
+ "Latest": latest,
+ }
+
+ buf := &mockableBuffer{}
+ // Protects the special test header by enabling it's use only while running `go test`
+ if testing.Testing() && r.Header.Get(testBufferHeader) != "" {
+ // This write error will cause an error in the template.Execute() below
+ buf.setWriteError(fmt.Errorf("mock error"))
+ }
+ if err := ua.tmplDashboard.Execute(buf, data); err != nil {
+ usecases.LogError(ua.Owner, "execute dashboard: %s", err)
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ buf.WriteTo(w) // Ignoring errors, can't do much with them anyways if the transfer fails
+}
diff --git a/messenger/messenger_test.go b/messenger/messenger_test.go
new file mode 100644
index 0000000..be9d0e3
--- /dev/null
+++ b/messenger/messenger_test.go
@@ -0,0 +1,103 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/sdoque/mbaigo/components"
+)
+
+type errorReader struct{}
+
+func (er *errorReader) Read([]byte) (int, error) {
+ return 0, fmt.Errorf("read error")
+}
+
+func (er *errorReader) Close() error {
+ return fmt.Errorf("close error")
+}
+
+func TestHandleNewMessage(t *testing.T) {
+ table := []struct {
+ expectedStatus int
+ method string
+ content string
+ body io.ReadCloser
+ }{
+ // Method not post
+ {http.StatusMethodNotAllowed, http.MethodGet, "", nil},
+ // Read body error
+ {http.StatusInternalServerError, http.MethodPost, "", &errorReader{}},
+ // Unpack error
+ {http.StatusBadRequest, http.MethodPost, "bad type", nil},
+ // Wrong form
+ {http.StatusBadRequest, http.MethodPost, "application/json",
+ io.NopCloser(strings.NewReader(`{"version":"MessengerRegistration_v1"}`)),
+ },
+ // All ok
+ {http.StatusOK, http.MethodPost, "application/json",
+ io.NopCloser(strings.NewReader(`{"version":"SystemMessage_v1","system":"test"}`)),
+ },
+ }
+
+ ua := &UnitAsset{
+ messages: make(map[string][]message),
+ }
+ for _, test := range table {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(test.method, "/message", test.body)
+ req.Header.Set("Content-Type", test.content)
+ ua.handleNewMessage(rec, req)
+
+ res := rec.Result()
+ if got, want := res.StatusCode, test.expectedStatus; got != want {
+ t.Errorf("expected status %d, got %d", want, got)
+ }
+ }
+}
+
+func TestHandleDashboard(t *testing.T) {
+ table := []struct {
+ expectedStatus int
+ method string
+ badMockBuff bool
+ }{
+ // Method not GET
+ {http.StatusMethodNotAllowed, http.MethodPost, false},
+ // Template fails executing
+ {http.StatusInternalServerError, http.MethodGet, true},
+ // All ok
+ {http.StatusOK, http.MethodGet, false},
+ }
+
+ tmpl, err := template.New("dashboard").Parse(tmplDashboard)
+ if err != nil {
+ t.Fatalf("expected no error from template.Parse, got %v", err)
+ }
+ sys := components.NewSystem("test sys", context.Background())
+ ua := &UnitAsset{
+ Owner: &sys,
+ messages: make(map[string][]message),
+ tmplDashboard: tmpl,
+ }
+ for _, test := range table {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(test.method, "/message", nil)
+ if test.badMockBuff {
+ // Triggers the mock buffer error
+ req.Header.Set(testBufferHeader, "true")
+ }
+ ua.handleDashboard(rec, req)
+
+ res := rec.Result()
+ if got, want := res.StatusCode, test.expectedStatus; got != want {
+ t.Errorf("expected status %d, got %d", want, got)
+ }
+ }
+}
diff --git a/messenger/thing.go b/messenger/thing.go
new file mode 100644
index 0000000..14faefb
--- /dev/null
+++ b/messenger/thing.go
@@ -0,0 +1,254 @@
+package main
+
+import (
+ "bytes"
+ _ "embed"
+ "fmt"
+ "html/template"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+type message struct {
+ time time.Time
+ level forms.MessageLevel
+ system string
+ body string
+}
+
+func (m message) String() string {
+ return fmt.Sprintf("%s - %s - %s: %s",
+ m.system,
+ m.time.Format("2006-01-02 15:04:05"),
+ forms.LevelToString(m.level),
+ m.body,
+ )
+}
+
+type UnitAsset struct {
+ Name string `json:"name"`
+ Owner *components.System `json:"-"`
+ Details map[string][]string `json:"details"`
+ ServicesMap components.Services `json:"-"`
+ CervicesMap components.Cervices `json:"-"`
+
+ cachedRegMsg []byte // Caches the MessengerRegistration form
+ messages map[string][]message // Per system msg log
+ mutex sync.RWMutex // Protects concurrent access to previous field
+ tmplDashboard *template.Template // The HTML template loaded from file
+}
+
+func (ua *UnitAsset) GetName() string { return ua.Name }
+
+func (ua *UnitAsset) GetServices() components.Services { return ua.ServicesMap }
+
+func (ua *UnitAsset) GetCervices() components.Cervices { return ua.CervicesMap }
+
+func (ua *UnitAsset) GetDetails() map[string][]string { return ua.Details }
+
+var _ components.UnitAsset = (*UnitAsset)(nil)
+
+func initTemplate() components.UnitAsset {
+ service := components.Service{
+ Definition: "message",
+ SubPath: "message",
+ Details: map[string][]string{"Forms": {"SystemMessage_v1"}},
+ RegPeriod: 30,
+ Description: "stores a new message in the log database",
+ }
+ return &UnitAsset{
+ Name: "log",
+ Details: map[string][]string{},
+ ServicesMap: components.Services{service.SubPath: &service},
+ }
+}
+
+// Instructs the compiler to load and embed the following file into the built binary
+
+//go:embed dashboard.html
+var tmplDashboard string
+
+func newResource(ca usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func(), error) {
+ ua := &UnitAsset{
+ Name: ca.Name,
+ Owner: sys,
+ Details: ca.Details,
+ ServicesMap: usecases.MakeServiceMap(ca.Services),
+ messages: make(map[string][]message),
+ }
+
+ var err error
+ ua.tmplDashboard, err = template.New("dashboard").Parse(tmplDashboard)
+ if err != nil {
+ return nil, nil, err
+ }
+ ua.cachedRegMsg, err = newRegMsg(sys)
+ if err != nil {
+ return nil, nil, err
+ }
+ go ua.runBeacon()
+ f := func() {}
+ return ua, f, nil
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+// newRegMsg creates a new MessengerRegistration form filled with the system's URL.
+// The form is then packed and cached, to be sent periodically by the beacon function.
+func newRegMsg(sys *components.System) ([]byte, error) {
+ // This system URL is created in the same way as the registrar,
+ // in getUniqueSystems(). Using url.URL instead for safer string assembly.
+ // https://github.com/lmas/mbaigo_systems/blob/dev/esr/thing.go#L404-L407
+ var systemURL url.URL
+ systemURL.Host = sys.Host.IPAddresses[0]
+ systemURL.Scheme = "https"
+ port := sys.Husk.ProtoPort[systemURL.Scheme]
+ if port == 0 {
+ systemURL.Scheme = "http"
+ port = sys.Husk.ProtoPort[systemURL.Scheme]
+ if port == 0 {
+ return nil, fmt.Errorf("no http(s) port defined in conf")
+ }
+ }
+ systemURL.Host += ":" + strconv.Itoa(port)
+ systemURL.Path = sys.Name
+ registration := forms.NewMessengerRegistration_v1(systemURL.String())
+ return usecases.Pack(forms.Form(®istration), "application/json")
+}
+
+const beaconPeriod int = 30
+
+// runBeacon runs periodically in the background (in a goroutine at startup).
+// It fetches a list of systems and then sends out a MessengerRegistration to each.
+func (ua *UnitAsset) runBeacon() {
+ for {
+ systems, err := ua.fetchSystems()
+ if err != nil {
+ usecases.LogInfo(ua.Owner, "error fetching system list: %s", err)
+ }
+ ua.notifySystems(systems)
+ select {
+ case <-time.Tick(time.Duration(beaconPeriod) * time.Second):
+ case <-ua.Owner.Ctx.Done():
+ return
+ }
+ }
+}
+
+// sendRequest is a helper for sending json web requests.
+// It returns either error or the response body as a byte array.
+func sendRequest(method, url string, body []byte) ([]byte, error) {
+ req, err := http.NewRequest(method, url, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode > 299 {
+ return nil, fmt.Errorf("bad response: %s", resp.Status)
+ }
+ return io.ReadAll(resp.Body)
+}
+
+// fetchSystems asks the registrar for a list of online systems.
+func (ua *UnitAsset) fetchSystems() (systems []string, err error) {
+ url, err := components.GetRunningCoreSystemURL(
+ ua.Owner, components.ServiceRegistrarName)
+ if err != nil {
+ return
+ }
+ body, err := sendRequest("GET", url+"/syslist", nil)
+ if err != nil {
+ return
+ }
+ form, err := usecases.Unpack(body, "application/json")
+ if err != nil {
+ return
+ }
+ records, ok := form.(*forms.SystemRecordList_v1)
+ if !ok {
+ err = fmt.Errorf("form is not a SystemRecordList_v1")
+ return
+ }
+ return records.List, nil
+}
+
+// notifySystems sends a pre-packed MessengerRegistration form to a list of online systems.
+// Any systems with incorrect URLs, any messengers, and any http errors will be ignored.
+func (ua *UnitAsset) notifySystems(list []string) {
+ for _, sys := range list {
+ sysURL, err := url.Parse(sys)
+ if err != nil {
+ continue // Skip misconfigured systems
+ }
+ if strings.HasPrefix(sysURL.Path, "/"+ua.Owner.Name) {
+ continue // Skip itself and other messengers
+ }
+ // Don't care about any errors or any systems that don't want to talk with us
+ // (using empty variable names to shut up the linter warning about unhandled errors)
+ _, _ = sendRequest("POST", sys+"/msg", ua.cachedRegMsg)
+ }
+}
+
+const maxMessages int = 10
+
+// addMessage adds the new message m to a system's log and optionally removes the
+// oldest, if the log's size is larger than maxMessages.
+// Note that this function sets the timestamp of the incoming msg too.
+func (ua *UnitAsset) addMessage(msg forms.SystemMessage_v1) {
+ ua.mutex.Lock()
+ defer ua.mutex.Unlock()
+ ua.messages[msg.System] = append(ua.messages[msg.System], message{
+ time: time.Now(),
+ level: msg.Level,
+ system: msg.System,
+ body: msg.Body,
+ })
+ if len(ua.messages[msg.System]) > maxMessages {
+ // Strips the oldest msg from the front of the slice
+ ua.messages[msg.System] = ua.messages[msg.System][1:]
+ }
+}
+
+// filterLogs fetches the latest errors/warnings/all messages from the log.
+// The log is appended to in a chronological order already, so the latest error
+// and warning for each system will be returned and "all" will be in reverse
+// chronological order.
+// NOTE: No tests are provided for this function, as it's most likely subject
+// to later changes.
+func (ua *UnitAsset) filterLogs() (errors, warnings map[string]message, all []message) {
+ errors = make(map[string]message)
+ warnings = make(map[string]message)
+ ua.mutex.RLock()
+ for system := range ua.messages {
+ for _, msg := range ua.messages[system] {
+ all = append(all, msg)
+ switch msg.level {
+ case forms.LevelError:
+ errors[system] = msg
+ case forms.LevelWarn:
+ warnings[system] = msg
+ }
+ }
+ }
+ ua.mutex.RUnlock()
+ // Reverse order
+ sort.Slice(all, func(i, j int) bool {
+ return all[i].time.After(all[j].time)
+ })
+ return
+}
diff --git a/messenger/thing_test.go b/messenger/thing_test.go
new file mode 100644
index 0000000..bd75ce0
--- /dev/null
+++ b/messenger/thing_test.go
@@ -0,0 +1,243 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+)
+
+func TestNewRegMsg(t *testing.T) {
+ table := []struct {
+ scheme string
+ expectErr bool
+ }{
+ // Missing both ports
+ {"", true},
+ // Having http port
+ {"http", false},
+ // Having https port
+ {"https", false},
+ }
+
+ sys := components.NewSystem("test sys", context.Background())
+ sys.Husk = &components.Husk{
+ ProtoPort: map[string]int{"https": 0, "http": 0},
+ }
+ for _, test := range table {
+ sys.Husk.ProtoPort[test.scheme] = 8080
+ body, err := newRegMsg(&sys)
+
+ if got, want := err != nil, test.expectErr; got != want {
+ t.Errorf("expected error %v, got: %v", want, err)
+ }
+
+ if got, want := len(body) < 1, test.expectErr; got != want {
+ t.Errorf("expected body %v, got: %s", want, string(body))
+ }
+ if got, want := bytes.Contains(body, []byte(test.scheme)), true; got != want {
+ t.Errorf("expected URL scheme %v in body, but it's missing", test.scheme)
+ }
+ }
+}
+
+type transSendRequest struct {
+ errResponse error
+ status int
+ body io.Reader
+}
+
+func newTransSendRequest() *transSendRequest {
+ lt := &transSendRequest{}
+ http.DefaultClient.Transport = lt
+ return lt
+}
+
+// This mock transport also verifies that the system message forms are valid.
+func (mock *transSendRequest) RoundTrip(req *http.Request) (*http.Response, error) {
+ defer req.Body.Close()
+ if mock.errResponse != nil {
+ return nil, mock.errResponse
+ }
+ rec := httptest.NewRecorder()
+ rec.WriteHeader(mock.status)
+ res := rec.Result()
+ res.Body = io.NopCloser(mock.body)
+ return res, nil
+}
+
+var errMock = fmt.Errorf("mock error")
+var bodyMock = "body ok"
+
+func TestSendRequest(t *testing.T) {
+ table := []struct {
+ method string
+ err error
+ status int
+ body io.Reader
+ expectErr bool
+ }{
+ // Bad method
+ {"no method", nil, 0, nil, true},
+ // Error from defaultclient
+ {http.MethodGet, errMock, 0, nil, true},
+ // Bad status code
+ {http.MethodGet, nil, http.StatusInternalServerError, nil, true},
+ // Error from body
+ {http.MethodGet, nil, http.StatusOK, &errorReader{}, true},
+ // All ok
+ {http.MethodGet, nil, http.StatusOK, strings.NewReader(bodyMock), false},
+ }
+
+ mock := newTransSendRequest()
+ for _, test := range table {
+ mock.errResponse = test.err
+ mock.status = test.status
+ mock.body = test.body
+ body, err := sendRequest(test.method, "/test/url", nil)
+
+ if got, want := err != nil, test.expectErr; got != want {
+ t.Errorf("expected error %v, got: %v", want, err)
+ }
+ if !test.expectErr && string(body) != bodyMock {
+ t.Errorf("expected body '%s', got '%s'", bodyMock, string(body))
+ }
+ }
+}
+
+type transFetchSystems struct {
+ t *testing.T
+ coreStatus int
+ reqStatus int
+ body string
+}
+
+func newTransFetchSystems(t *testing.T) *transFetchSystems {
+ lt := &transFetchSystems{t: t}
+ http.DefaultClient.Transport = lt
+ return lt
+}
+
+// This mock transport also verifies that the system message forms are valid.
+func (mock *transFetchSystems) RoundTrip(req *http.Request) (*http.Response, error) {
+ if req.Body != nil {
+ req.Body.Close()
+ }
+ rec := httptest.NewRecorder()
+ switch req.URL.Path {
+ case "/status":
+ rec.WriteHeader(mock.coreStatus)
+ if mock.coreStatus == http.StatusOK {
+ fmt.Fprint(rec.Body, components.ServiceRegistrarLeader)
+ }
+ case "/syslist":
+ rec.WriteHeader(mock.reqStatus)
+ fmt.Fprint(rec.Body, mock.body)
+ default:
+ mock.t.Errorf("unexpected path: %s", req.URL.Path)
+ rec.WriteHeader(http.StatusInternalServerError)
+ }
+ return rec.Result(), nil
+}
+
+func TestFetchSystems(t *testing.T) {
+ table := []struct {
+ coreStatus int
+ reqStatus int
+ expectErr bool
+ body string
+ }{
+ // Error from GetRunningCoreSystemURL
+ {http.StatusInternalServerError, 0, true, ""},
+ // Error from sendRequest
+ {http.StatusOK, http.StatusInternalServerError, true, ""},
+ // Error from Unpack
+ {http.StatusOK, http.StatusOK, true, ""},
+ // Error from form
+ {http.StatusOK, http.StatusOK, true, `{"version":"MessengerRegistration_v1"}`},
+ // All ok
+ {http.StatusOK, http.StatusOK, false,
+ `{"version":"SystemRecordList_v1", "systemurl":["http://test"]}`,
+ },
+ }
+
+ sys := components.NewSystem("test sys", context.Background())
+ sys.CoreS = []*components.CoreSystem{
+ {Name: "serviceregistrar", Url: "http://fake"},
+ }
+ ua := &UnitAsset{
+ Owner: &sys,
+ }
+ mock := newTransFetchSystems(t)
+
+ for _, test := range table {
+ mock.coreStatus = test.coreStatus
+ mock.reqStatus = test.reqStatus
+ mock.body = test.body
+ list, err := ua.fetchSystems()
+
+ if got, want := err != nil, test.expectErr; got != want {
+ t.Errorf("expected error %v, got: %v", want, err)
+ }
+ if err != nil {
+ continue
+ }
+ if got, want := len(list), 1; got != want {
+ t.Errorf("expected %d urls in list, got %d", want, got)
+ }
+ }
+}
+
+func TestNotifySystems(t *testing.T) {
+ name := "test messenger"
+ urls := []string{
+ "\x00bad", // Bad URLs
+ "/" + name, // Skip itself
+ "/good", // All ok
+ }
+ sys := components.NewSystem(name, context.Background())
+ ua := &UnitAsset{
+ Owner: &sys,
+ }
+ mock := newTransSendRequest()
+ mock.status = http.StatusOK
+ mock.body = strings.NewReader("ok") // Required for sendRequest()
+ for _, test := range urls {
+ ua.notifySystems([]string{test})
+ }
+}
+
+func TestAddMessage(t *testing.T) {
+ sys := "test"
+ ua := &UnitAsset{
+ messages: make(map[string][]message),
+ }
+ for i := range maxMessages * 2 {
+ msg := forms.SystemMessage_v1{
+ Level: forms.LevelDebug,
+ System: sys,
+ Body: fmt.Sprintf("%d", i),
+ }
+ ua.addMessage(msg)
+ }
+
+ size := len(ua.messages[sys])
+ if got, want := size, maxMessages; got > want {
+ t.Errorf("expected max messages %d, got %d", want, got)
+ }
+ oldest := ua.messages[sys][0]
+ if got, want := oldest.body, fmt.Sprintf("%d", maxMessages); got != want {
+ t.Errorf("expected oldest msg '%s', got '%s'", want, got)
+ }
+ newest := ua.messages[sys][size-1]
+ if got, want := newest.body, fmt.Sprintf("%d", maxMessages*2-1); got != want {
+ t.Errorf("expected newest msg '%s', got '%s'", want, got)
+ }
+}
diff --git a/orchestrator/README.md b/orchestrator/README.md
index 6d18e4d..c6a9765 100644
--- a/orchestrator/README.md
+++ b/orchestrator/README.md
@@ -9,11 +9,9 @@ In the current state, the Orchestrator forwards this request to the Service Regi
The Orchestrator has more responsibilities, such as checking the authorization for a system to consume a specific service from another system. These will be implemented in the future.
## Compiling
-To compile the code, one needs to get the AiGo module
-```go get github.com/sdoque/mbaigo```
-and initialize the *go.mod* file with ``` go mod init github.com/sdoque/systems/orchestrator``` before running *go mod tidy*.
+To compile the code, one needs to initialize the *go.mod* file with ``` go mod init github.com/sdoque/systems/orchestrator``` before running *go mod tidy*.
-To run the code, one just needs to type in ```go run orchestrator.go thing.go``` within a terminal or at a command prompt.
+To run the code, one just needs to type in ```go run .``` within a terminal or at a command prompt.
It is **important** to start the program from within its own directory (and each system should have their own directory) because it looks for its configuration file there. If it does not find it there, it will generate one and shutdown to allow the configuration file to be updated.
@@ -25,15 +23,15 @@ To build the software for one's own machine,
## Cross compiling/building
The following commands enable one to build for different platforms:
-- Intel Mac: ```GOOS=darwin GOARCH=amd64 go build -o orchestrator_imac orchestrator.go thing.go```
-- ARM Mac: ```GOOS=darwin GOARCH=arm64 go build -o orchestrator_amac orchestrator.go thing.go```
-- Windows 64: ```GOOS=windows GOARCH=amd64 go build -o orchestrator.exe orchestrator.go thing.go```
-- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o orchestrator_rpi64 orchestrator.go thing.go```
-- Linux: ```GOOS=linux GOARCH=amd64 go build -o -o orchestrator_linux orchestrator.go thing.go```
+- Intel Mac: ```GOOS=darwin GOARCH=amd64 go build -o orchestrator_imac```
+- ARM Mac: ```GOOS=darwin GOARCH=arm64 go build -o orchestrator_amac```
+- Windows 64: ```GOOS=windows GOARCH=amd64 go build -o orchestrator.exe```
+- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o orchestrator_rpi64```
+- Linux: ```GOOS=linux GOARCH=amd64 go build -o orchestrator_linux```
One can find a complete list of platform by typing *go tool dist list* at the command prompt
If one wants to secure copy it to a Raspberry pi,
-`scp orchestrator_rpi64 username@ipAddress:mbaigo/orchestrator/` where user is the *username* @ the *IP address* of the Raspberry Pi with a relative (to the user's home directory) location *mbaigo/orchestrator/* directory.
+`scp orchestrator_rpi64 username@ipAddress:rpiExec/orchestrator/` where user is the *username* @ the *IP address* of the Raspberry Pi with a relative (to the user's home directory) location *rpiExec/orchestrator/* directory.
Additionally, one must ensure that the file is an executable file (e.g., ```chmod +x orchestrator_rpi64```).
\ No newline at end of file
diff --git a/orchestrator/extra_utils_test.go b/orchestrator/extra_utils_test.go
new file mode 100644
index 0000000..67e728f
--- /dev/null
+++ b/orchestrator/extra_utils_test.go
@@ -0,0 +1,118 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+
+ "github.com/sdoque/mbaigo/components"
+)
+
+// mockTransport is used for replacing the default network Transport (used by
+// http.DefaultClient) and it will intercept network requests.
+type mockTransport struct {
+ respFunc func() *http.Response
+ hits int
+ err error
+}
+
+func newMockTransport(respFunc func() *http.Response, v int, err error) *mockTransport {
+ t := &mockTransport{
+ respFunc: respFunc,
+ hits: v,
+ err: err,
+ }
+ // Hijack the default http client so no actual http requests are sent over the network
+ http.DefaultClient.Transport = t
+ return t
+}
+
+// RoundTrip method is required to fulfil the RoundTripper interface (as required by the DefaultClient).
+// It prevents the request from being sent over the network, and count how many times
+// a http request was sent
+func (t *mockTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) {
+ t.hits -= 1
+ if t.hits == 0 {
+ return resp, t.err
+ }
+ resp = t.respFunc()
+ resp.Request = req
+ return resp, nil
+}
+
+func createSystemWithUnitAsset() components.System {
+ ctx := context.Background()
+ sys := components.NewSystem("testSystem", ctx)
+
+ leadingRegistrar := &components.CoreSystem{
+ Name: components.ServiceRegistrarName,
+ Url: "http://localhost:20102/serviceregistrar/registry",
+ }
+ sys.CoreS = []*components.CoreSystem{
+ leadingRegistrar,
+ }
+ return sys
+}
+
+func createUnitAsset() *UnitAsset {
+ // Define the services that expose the capabilities of the unit asset(s)
+ squest := components.Service{
+ Definition: "squest",
+ SubPath: "squest",
+ Details: map[string][]string{"DefaultForm": {"ServiceRecord_v1"}, "Location": {"LocalCloud"}},
+ Description: "looks for the desired service described in a quest form (POST)",
+ }
+
+ // create the unit asset template
+ uat := &UnitAsset{
+ Name: "orchestration",
+ Details: map[string][]string{"Platform": {"Independent"}},
+ leadingRegistrar: "",
+ ServicesMap: components.Services{
+ squest.SubPath: &squest, // Inline assignment of the temperature service
+ },
+ }
+
+ sys := createSystemWithUnitAsset()
+ uat.Owner = &sys
+
+ return uat
+}
+
+type errorReader struct{}
+
+func (errorReader) Read(p []byte) (int, error) {
+ return 0, fmt.Errorf("forced read error")
+}
+
+type mockResponseWriter struct {
+ *httptest.ResponseRecorder
+ writeError bool
+}
+
+func (e *mockResponseWriter) Write(b []byte) (int, error) {
+ if e.writeError {
+ return 0, fmt.Errorf("Forced write error")
+ }
+ return e.ResponseRecorder.Write(b)
+}
+
+func (e *mockResponseWriter) WriteHeader(statusCode int) {
+ e.ResponseRecorder.Code = statusCode
+}
+
+func (e *mockResponseWriter) Header() http.Header {
+ return e.ResponseRecorder.Header()
+}
+
+func newMockResponseWriter() *mockResponseWriter {
+ return &mockResponseWriter{
+ ResponseRecorder: httptest.NewRecorder(),
+ writeError: true,
+ }
+}
+
+var brokenUrl = string(rune(0))
+
+var errHTTP error = fmt.Errorf("bad http request")
diff --git a/orchestrator/go.mod b/orchestrator/go.mod
new file mode 100644
index 0000000..85bde11
--- /dev/null
+++ b/orchestrator/go.mod
@@ -0,0 +1,8 @@
+module github.com/sdoque/systems/orchestrator
+
+go 1.24.4
+
+require github.com/sdoque/mbaigo v0.0.0-20250520155324-7390c339652a
+
+// Replaces this library with a patched version
+replace github.com/sdoque/mbaigo v0.0.0-20250520155324-7390c339652a => github.com/lmas/mbaigo v0.0.0-20250715100940-0fef178d190b
diff --git a/orchestrator/go.sum b/orchestrator/go.sum
new file mode 100644
index 0000000..ef3db4b
--- /dev/null
+++ b/orchestrator/go.sum
@@ -0,0 +1,2 @@
+github.com/lmas/mbaigo v0.0.0-20250715100940-0fef178d190b h1:4I+X0TTj6E2RvaAIG8EV7TzZ9O4oPOOBJiWR/otOOJg=
+github.com/lmas/mbaigo v0.0.0-20250715100940-0fef178d190b/go.mod h1:vXE1mDd88Tap9bHm1elrk3Ht8bcImA3FeiSM03yUwsM=
diff --git a/orchestrator/orchestrator.go b/orchestrator/orchestrator.go
index 888db94..8c34177 100644
--- a/orchestrator/orchestrator.go
+++ b/orchestrator/orchestrator.go
@@ -15,8 +15,8 @@ package main
import (
"context"
+ "crypto/x509/pkix"
"encoding/json"
- "fmt"
"io"
"log"
"mime"
@@ -30,8 +30,8 @@ import (
func main() {
// prepare for graceful shutdown
- ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled
- defer cancel() // make sure all paths cancel the context to avoid context leak
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
// instantiate the System
sys := components.NewSystem("orchestrator", ctx)
@@ -43,6 +43,14 @@ func main() {
Details: map[string][]string{"Developer": {"Arrowhead"}},
ProtoPort: map[string]int{"https": 0, "http": 20103, "coap": 0},
InfoLink: "https://github.com/sdoque/systems/tree/main/orchestrator",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
}
// instantiate a template unit asset
@@ -51,17 +59,17 @@ func main() {
sys.UAssets[assetName] = &assetTemplate
// Configure the system
- rawResources, servsTemp, err := usecases.Configure(&sys)
+ rawResources, err := usecases.Configure(&sys)
if err != nil {
log.Fatalf("Configuration error: %v\n", err)
}
sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
for _, raw := range rawResources {
- var uac UnitAsset
+ var uac usecases.ConfigurableAsset
if err := json.Unmarshal(raw, &uac); err != nil {
log.Fatalf("Resource configuration error: %+v\n", err)
}
- ua, cleanup := newResource(uac, &sys, servsTemp)
+ ua, cleanup := newResource(uac, &sys)
defer cleanup()
sys.UAssets[ua.GetName()] = &ua
}
@@ -77,9 +85,10 @@ func main() {
// wait for shutdown signal, and gracefully close properly goroutines with context
<-sys.Sigs // wait for a SIGINT (Ctrl+C) signal
- fmt.Println("\nshuting down system", sys.Name)
- cancel() // cancel the context, signaling the goroutines to stop
- time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end
+ log.Println("shutting down system", sys.Name)
+ cancel() // signal the goroutines to stop
+ // allow the go routines to be executed, which might take more time than the main routine to end
+ time.Sleep(2 * time.Second)
}
// Serving handles the resources services. NOTE: it expects those names from the request URL path
@@ -87,7 +96,8 @@ func (ua *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath
switch servicePath {
case "squest":
ua.orchestrate(w, r)
-
+ case "squests":
+ ua.orchestrateMultiple(w, r)
default:
http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest)
}
@@ -100,12 +110,12 @@ func (ua *UnitAsset) orchestrate(w http.ResponseWriter, r *http.Request) {
contentType := r.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
- fmt.Println("Error parsing media type:", err)
+ log.Println("Error parsing media type:", err)
return
}
defer r.Body.Close()
- bodyBytes, err := io.ReadAll(r.Body) // Use io.ReadAll instead of ioutil.ReadAll
+ bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("error reading discovery request body: %v\n", err)
return
@@ -115,10 +125,9 @@ func (ua *UnitAsset) orchestrate(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("error extracting the discovery request %v\n", err)
}
- // Perform a type assertion to convert the returned Form to SignalA_v1a
qf, ok := questForm.(*forms.ServiceQuest_v1)
if !ok {
- fmt.Println("Problem unpacking the service discovery request form")
+ log.Println("Problem unpacking the service discovery request form")
return
}
@@ -131,7 +140,53 @@ func (ua *UnitAsset) orchestrate(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
- _, err = w.Write(servLocation) // respond with the selected servicelocation
+ _, err = w.Write(servLocation) // respond with the selected service location
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ default:
+ http.Error(w, "Method is not supported.", http.StatusNotFound)
+ }
+}
+
+func (ua *UnitAsset) orchestrateMultiple(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "POST":
+ contentType := r.Header.Get("Content-Type")
+ mediaType, _, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ log.Println("Error parsing media type:", err)
+ return
+ }
+
+ defer r.Body.Close()
+ bodyBytes, err := io.ReadAll(r.Body)
+ if err != nil {
+ log.Printf("error reading discovery request body: %v\n", err)
+ return
+ }
+
+ questForm, err := usecases.Unpack(bodyBytes, mediaType)
+ if err != nil {
+ log.Printf("error extracting the discovery request %v\n", err)
+ }
+ qf, ok := questForm.(*forms.ServiceQuest_v1)
+ if !ok {
+ log.Println("Problem unpacking the service discovery request form")
+ return
+ }
+
+ servLocation, err := ua.getServicesURL(*qf)
+ if err != nil {
+ log.Println(err)
+ http.Error(w, err.Error(), http.StatusServiceUnavailable)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, err = w.Write(servLocation) // respond with the selected service location
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
diff --git a/orchestrator/orchestrator_test.go b/orchestrator/orchestrator_test.go
new file mode 100644
index 0000000..bc3fc54
--- /dev/null
+++ b/orchestrator/orchestrator_test.go
@@ -0,0 +1,243 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/sdoque/mbaigo/forms"
+)
+
+func TestServing(t *testing.T) {
+ inputW := httptest.NewRecorder()
+ inputR := httptest.NewRequest(http.MethodPost, "/test123",
+ io.NopCloser(strings.NewReader(string(createTestServiceQuestForm()))))
+ inputR.Header.Set("Content-Type", "application/json")
+ newMockTransport(createMultiHTTPResponse(2, false, string(createTestServiceRecordListForm())), 0, nil)
+ mua := createUnitAsset()
+ mua.Serving(inputW, inputR, "squest")
+
+ var expectedOutput = string(createTestServicePointForm())
+
+ if inputW.Body.String() != expectedOutput || inputW.Code != 200 {
+ t.Errorf("Expected %s and code %d, got: %s and code %d",
+ expectedOutput, 200, inputW.Body.String(), inputW.Code)
+ }
+
+ inputW = httptest.NewRecorder()
+ inputR = httptest.NewRequest(http.MethodPost, "/test123",
+ io.NopCloser(strings.NewReader(string(createTestServiceQuestForm()))))
+ inputR.Header.Set("Content-Type", "application/json")
+ newMockTransport(createMultiHTTPResponse(2, false, string(createTestServiceRecordListForm())), 0, nil)
+ mua = createUnitAsset()
+ mua.Serving(inputW, inputR, "squests")
+
+ expectedOutput = string(createTestServiceRecordListForm())
+
+ if inputW.Body.String() != expectedOutput || inputW.Code != 200 {
+ t.Errorf("Expected %s and code %d, got: %s and code %d",
+ expectedOutput, 200, inputW.Body.String(), inputW.Code)
+ }
+
+ inputW = httptest.NewRecorder()
+ inputR = httptest.NewRequest(http.MethodPost, "/test123", io.NopCloser(strings.NewReader("")))
+ inputR.Header.Set("Content-Type", "application/json")
+ newMockTransport(createMultiHTTPResponse(2, false, string(createTestServiceRecordListForm())), 0, nil)
+ mua = createUnitAsset()
+ mua.Serving(inputW, inputR, "wrong")
+
+ if inputW.Code == 200 {
+ t.Errorf("Expected the error code to not be 200 when having servicePath not be squest")
+ }
+}
+
+func createMultiHTTPResponse(limit int, writeError bool, body string) func() *http.Response {
+ count := 0
+ return func() *http.Response {
+ resp := &http.Response{
+ Status: "200 OK",
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: nil,
+ }
+ count++
+ if count == limit && writeError == true {
+ resp.Body = io.NopCloser(errorReader{})
+ return resp
+ }
+ if count == limit {
+ resp.Body = io.NopCloser(strings.NewReader(body))
+ return resp
+ }
+ resp.Body = io.NopCloser(strings.NewReader(string("lead Service Registrar since")))
+ return resp
+ }
+}
+
+var serviceQuestForm forms.ServiceQuest_v1
+
+func createTestServiceQuestForm() []byte {
+ serviceQuestForm.NewForm()
+ fakebody, err := json.Marshal(serviceQuestForm)
+ if err != nil {
+ panic(fmt.Sprintf("Fail marshal at start of test: %v", err))
+ }
+ return fakebody
+}
+
+var servicePointForm forms.ServicePoint_v1
+
+func createTestServicePointForm() []byte {
+ servicePointForm.NewForm()
+ servicePointForm.ServLocation = "http://123.456.789:123//"
+ fakebody, err := json.MarshalIndent(servicePointForm, "", " ")
+ if err != nil {
+ panic(fmt.Sprintf("Fail marshal at start of test: %v", err))
+ }
+ return fakebody
+}
+
+var serviceRecordForm forms.ServiceRecord_v1
+
+var serviceRecord2Form forms.ServiceRecord_v1
+
+var serviceRecordListForm forms.ServiceRecordList_v1
+
+func createTestServiceRecordListForm() []byte {
+ serviceRecordForm.NewForm()
+ serviceRecordForm.IPAddresses = []string{"123.456.789"}
+ serviceRecordForm.ProtoPort = map[string]int{"http": 123}
+ serviceRecord2Form.NewForm()
+ serviceRecord2Form.IPAddresses = []string{"123.456.789"}
+ serviceRecord2Form.ProtoPort = map[string]int{"http": 123}
+ serviceRecordListForm.NewForm()
+ serviceRecordListForm.List = []forms.ServiceRecord_v1{serviceRecordForm, serviceRecord2Form}
+ fakebody, err := json.MarshalIndent(serviceRecordListForm, "", " ")
+ if err != nil {
+ panic(fmt.Sprintf("Fail marshal at start of test: %v", err))
+ }
+ return fakebody
+}
+
+var getServiceURLErrorMessage = "core system 'serviceregistrar' not found: verifying registrar: Get " +
+ "\"http://localhost:20102/serviceregistrar/registry/status\": http: RoundTripper implementation " +
+ "(*main.mockTransport) returned a nil *Response with a nil error\n"
+
+type orchestrateTestStruct struct {
+ inputBody io.ReadCloser
+ httpMethod string
+ contentType string
+ mockTransportErr int
+ expectedCode int
+ expectedOutput string
+ testName string
+}
+
+var orchestrateTestParams = []orchestrateTestStruct{
+ {io.NopCloser(strings.NewReader(string(createTestServiceQuestForm()))), "POST",
+ "application/json", 3, 200, string(createTestServicePointForm()), "Best case, everything passes"},
+ {io.NopCloser(strings.NewReader(string(createTestServiceQuestForm()))), "POST",
+ "", 3, 200, "", "Bad case, header content type is wrong"},
+ {io.NopCloser(errorReader{}), "POST",
+ "application/json", 3, 200, "", "Bad case, ReadAll on header body fails"},
+ {io.NopCloser(strings.NewReader(string("hej hej"))), "POST",
+ "text/plain", 3, 200, "", "Bad case, Unpack and type assertion to ServiceQuest_v1 fails"},
+ {io.NopCloser(strings.NewReader(string(createTestServiceQuestForm()))), "POST",
+ "application/json", 1, 503, getServiceURLErrorMessage, "Bad case, getServiceURL fails"},
+ {io.NopCloser(strings.NewReader(string(""))), "PUT",
+ "", 0, 404, "Method is not supported.\n", "Bad case, wrong http method"},
+}
+
+func TestOrchestrate(t *testing.T) {
+ for _, testCase := range orchestrateTestParams {
+ inputR := httptest.NewRequest(testCase.httpMethod, "/test123", testCase.inputBody)
+ inputR.Header.Set("Content-Type", testCase.contentType)
+ mua := createUnitAsset()
+ newMockTransport(createMultiHTTPResponse(2, false, string(createTestServiceRecordListForm())),
+ testCase.mockTransportErr, nil)
+
+ inputW := httptest.NewRecorder()
+ inputW.Header()
+ mua.orchestrate(inputW, inputR)
+
+ if inputW.Body.String() != testCase.expectedOutput || inputW.Result().StatusCode != testCase.expectedCode {
+ t.Errorf("In test case: %s: Expected %s, got: %s",
+ testCase.testName, testCase.expectedOutput, inputW.Body.String())
+ }
+ }
+
+ // Special case
+ inputR := httptest.NewRequest(http.MethodPost, "/test123",
+ io.NopCloser(strings.NewReader(string(createTestServiceQuestForm()))))
+ inputR.Header.Set("content-Type", "application/json")
+ mua := createUnitAsset()
+ newMockTransport(createMultiHTTPResponse(2, false, string(createTestServiceRecordListForm())), 3, nil)
+ inputW := newMockResponseWriter()
+ mua.orchestrate(inputW, inputR)
+
+ if inputW.ResponseRecorder.Body.String() != "" || inputW.ResponseRecorder.Code != 500 {
+ t.Errorf("In test case: Bad case, write fails: Expected: , and: 500, got: %s, and: %d",
+ inputW.ResponseRecorder.Body.String(), inputW.ResponseRecorder.Code)
+ }
+}
+
+type orchestrateMultipleTestStruct struct {
+ inputBody io.ReadCloser
+ httpMethod string
+ contentType string
+ mockTransportErr int
+ expectedCode int
+ expectedOutput string
+ testName string
+}
+
+var orchestrateMultipleTestParams = []orchestrateMultipleTestStruct{
+ {io.NopCloser(strings.NewReader(string(createTestServiceQuestForm()))), "POST",
+ "application/json", 3, 200, string(createTestServiceRecordListForm()), "Best case, everything passes"},
+ {io.NopCloser(strings.NewReader(string(createTestServiceQuestForm()))), "POST",
+ "", 3, 200, "", "Bad case, header content type is wrong"},
+ {io.NopCloser(errorReader{}), "POST",
+ "application/json", 3, 200, "", "Bad case, ReadAll on header body fails"},
+ {io.NopCloser(strings.NewReader(string("hej hej"))), "POST",
+ "text/plain", 3, 200, "", "Bad case, Unpack and type assertion to ServiceQuest_v1 fails"},
+ {io.NopCloser(strings.NewReader(string(createTestServiceQuestForm()))), "POST",
+ "application/json", 1, 503, getServiceURLErrorMessage, "Bad case, getServiceURL fails"},
+ {io.NopCloser(strings.NewReader(string(""))), "PUT",
+ "", 0, 404, "Method is not supported.\n", "Bad case, wrong http method"},
+}
+
+func TestOrchestrateMultiple(t *testing.T) {
+ for _, testCase := range orchestrateMultipleTestParams {
+ inputR := httptest.NewRequest(testCase.httpMethod, "/test123", testCase.inputBody)
+ inputR.Header.Set("Content-Type", testCase.contentType)
+ mua := createUnitAsset()
+ newMockTransport(createMultiHTTPResponse(2, false, string(createTestServiceRecordListForm())),
+ testCase.mockTransportErr, nil)
+ inputW := httptest.NewRecorder()
+ mua.orchestrateMultiple(inputW, inputR)
+
+ if inputW.Body.String() != testCase.expectedOutput || inputW.Code != testCase.expectedCode {
+ t.Errorf("In test case: %s: Expected %s, got: %s",
+ testCase.testName, testCase.expectedOutput, inputW.Body.String())
+ }
+ }
+
+ // Special case, write fails
+
+ inputR := httptest.NewRequest(http.MethodPost, "/test123",
+ io.NopCloser(strings.NewReader(string(createTestServiceQuestForm()))))
+ inputR.Header.Set("content-Type", "application/json")
+ mua := createUnitAsset()
+ newMockTransport(createMultiHTTPResponse(2, false, string(createTestServiceRecordListForm())), 3, nil)
+ inputW := newMockResponseWriter()
+ mua.orchestrateMultiple(inputW, inputR)
+
+ if inputW.ResponseRecorder.Body.String() != "" || inputW.ResponseRecorder.Code != 500 {
+ t.Errorf("In test case: Bad case, write fails: Expected: , and: 500, got: %s, and: %d",
+ inputW.ResponseRecorder.Body.String(), inputW.ResponseRecorder.Code)
+ }
+}
diff --git a/orchestrator/thing.go b/orchestrator/thing.go
index 6d73f2c..23eee2b 100644
--- a/orchestrator/thing.go
+++ b/orchestrator/thing.go
@@ -19,10 +19,9 @@ import (
"encoding/json"
"fmt"
"io"
- "log"
"net/http"
"strconv"
- "strings"
+
"time"
"github.com/sdoque/mbaigo/components"
@@ -34,13 +33,12 @@ import (
// UnitAsset type models the unit asset (interface) of the system.
type UnitAsset struct {
- Name string `json:"name"`
- Owner *components.System `json:"-"`
- Details map[string][]string `json:"details"`
- ServicesMap components.Services `json:"-"`
- CervicesMap components.Cervices `json:"-"`
- //
- leadingRegistrar *components.CoreSystem
+ Name string `json:"name"`
+ Owner *components.System `json:"-"`
+ Details map[string][]string `json:"details"`
+ ServicesMap components.Services `json:"-"`
+ CervicesMap components.Cervices `json:"-"`
+ leadingRegistrar string
}
// GetName returns the name of the Resource.
@@ -77,13 +75,21 @@ func initTemplate() components.UnitAsset {
Details: map[string][]string{"DefaultForm": {"ServiceRecord_v1"}, "Location": {"LocalCloud"}},
Description: "looks for the desired service described in a quest form (POST)",
}
+ squests := components.Service{
+ Definition: "squests",
+ SubPath: "squests",
+ Details: map[string][]string{"DefaultForm": {"ServiceRecord_v1"}, "Location": {"LocalCloud"}},
+ Description: "looks for the desired services described in a quest form (POST)",
+ }
- // var uat components.UnitAsset // this is an interface, which we then initialize
+ // create the unit asset template
uat := &UnitAsset{
- Name: "orchestration",
- Details: map[string][]string{"Platform": {"Independent"}},
+ Name: "orchestration",
+ Details: map[string][]string{"Platform": {"Independent"}},
+ leadingRegistrar: "",
ServicesMap: components.Services{
- squest.SubPath: &squest, // Inline assignment of the temperature service
+ squest.SubPath: &squest,
+ squests.SubPath: &squests,
},
}
return uat
@@ -92,20 +98,16 @@ func initTemplate() components.UnitAsset {
//-------------------------------------Instantiate the unit assets based on configuration
// newResource creates the Resource resource with its pointers and channels based on the configuration using the template
-func newResource(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) {
- // var ua components.UnitAsset // this is an interface, which we then initialize
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
ua := &UnitAsset{ // this is an interface, which we then initialize
- Name: uac.Name,
+ Name: configuredAsset.Name,
Owner: sys,
- Details: uac.Details,
- ServicesMap: components.CloneServices(servs),
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
}
- // start the unit asset(s)
- // no need to start the algorithm asset
-
return ua, func() {
- log.Println("Ending orchestration services")
+ // Do nothing
}
}
@@ -113,8 +115,10 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi
// getServiceURL retrieves the service URL for a given ServiceQuest_v1.
// It first checks if the leading registrar is still valid and updates it if necessary.
-// If no leading registrar is found, it iterates through the system's core services to find one.
-// Once a valid registrar is found, it sends a query to the registrar to get the service URL.
+// If no leading registrar is found, it iterates through the system's core services
+// to find one.
+// Once a valid registrar is found, it sends a query to the registrar to get the
+// service URL.
//
// Parameters:
// - newQuest: The ServiceQuest_v1 containing the service request details.
@@ -123,116 +127,57 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi
// - servLoc: A byte slice containing the service location in JSON format.
// - err: An error if any issues occur during the process.
func (ua *UnitAsset) getServiceURL(newQuest forms.ServiceQuest_v1) (servLoc []byte, err error) {
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) // Create a new context, with a 2-second timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
sys := ua.Owner
- if ua.leadingRegistrar != nil {
-
- // verify that this leading registrar is still leading
- resp, errs := http.Get(ua.leadingRegistrar.Url + "/status")
- if errs != nil {
- log.Println("lost leading registrar status:", errs)
- ua.leadingRegistrar = nil
- err = errs
- return // Skip to the next iteration of the loop
- }
-
- // Read from status resp.Body and then close it directly after
- bodyBytes, errs := io.ReadAll(resp.Body)
- resp.Body.Close() // Close the body directly after reading from it
- if errs != nil {
- log.Println("\rError reading response from leading registrar:", errs)
- ua.leadingRegistrar = nil
- err = errs
- return // Skip to the next iteration of the loop
- }
-
- // reset the pointer if the registrar lost its leading status
- if !strings.HasPrefix(string(bodyBytes), "lead Service Registrar since") {
- ua.leadingRegistrar = nil
- log.Println("lost previous leading registrar")
- }
- } else {
- for _, cSys := range sys.CoreS {
- core := cSys
- if core.Name == "serviceregistrar" {
- resp, err := http.Get(core.Url + "/status")
- if err != nil {
- fmt.Println("Error checking service registrar status:", err)
- ua.leadingRegistrar = nil // clear the leading registrar record
- continue // Skip to the next iteration of the loop
- }
-
- // Read from resp.Body and then close it directly after
- bodyBytes, err := io.ReadAll(resp.Body)
- resp.Body.Close() // Close the body directly after reading from it
- if err != nil {
- fmt.Println("Error reading service registrar response body:", err)
- continue // Skip to the next iteration of the loop
- }
-
- if strings.HasPrefix(string(bodyBytes), "lead Service Registrar since") {
- ua.leadingRegistrar = core
- fmt.Printf("\nlead registrar found at: %s\n", ua.leadingRegistrar.Url)
- }
- }
+ if ua.leadingRegistrar == "" {
+ ua.leadingRegistrar, err = components.GetRunningCoreSystemURL(sys, "serviceregistrar")
+ if err != nil {
+ return servLoc, err
}
}
// Create a new HTTP request to the the Service Registrar
-
- // Create buffer to save a copy of the request body
mediaType := "application/json"
jsonQF, err := usecases.Pack(&newQuest, mediaType)
if err != nil {
- log.Printf("problem encountered when marshalling the service quest\n")
return servLoc, err
}
- srURL := ua.leadingRegistrar.Url + "/query"
+ srURL := ua.leadingRegistrar + "/query"
req, err := http.NewRequest(http.MethodPost, srURL, bytes.NewBuffer(jsonQF))
if err != nil {
return servLoc, err
}
- req.Header.Set("Content-Type", mediaType) // set the Content-Type header
- req = req.WithContext(ctx) // associate the cancellable context with the request
+ req.Header.Set("Content-Type", mediaType)
+ req = req.WithContext(ctx)
- // forward the request to the leading Service Registrar/////////////////////////////////
- client := &http.Client{}
- resp, err := client.Do(req)
+ resp, err := http.DefaultClient.Do(req)
if err != nil {
- ua.leadingRegistrar = nil
+ ua.leadingRegistrar = ""
return servLoc, err
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
- log.Printf("Error reading discovery response body: %v", err)
return servLoc, err
}
- fmt.Printf("\n%v\n", string(respBytes))
serviceListf, err := usecases.Unpack(respBytes, mediaType)
if err != nil {
- log.Print("Error extracting discovery reply ", err)
return servLoc, err
}
- // Perform a type assertion to convert the returned Form to SignalA_v1a
serviceList, ok := serviceListf.(*forms.ServiceRecordList_v1)
if !ok {
- log.Println("problem asserting the type of the service list form")
- return
+ return nil, fmt.Errorf("problem asserting the type of the service list form")
}
if len(serviceList.List) == 0 {
- err = fmt.Errorf("unable to locate any such service: %s", newQuest.ServiceDefinition)
- return
+ return nil, fmt.Errorf("unable to locate any such service: %s", newQuest.ServiceDefinition)
}
- fmt.Printf("/n the length of the service list is: %d\n", len(serviceList.List))
serviceLocation := selectService(*serviceList)
payload, err := json.MarshalIndent(serviceLocation, "", " ")
- fmt.Printf("the service location is %+v\n", serviceLocation)
return payload, err
}
@@ -246,3 +191,57 @@ func selectService(serviceList forms.ServiceRecordList_v1) (sp forms.ServicePoin
sp.ServNode = rec.ServiceNode
return
}
+
+func (ua *UnitAsset) getServicesURL(newQuest forms.ServiceQuest_v1) (servLoc []byte, err error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ sys := ua.Owner
+ if ua.leadingRegistrar == "" {
+ ua.leadingRegistrar, err = components.GetRunningCoreSystemURL(sys, "serviceregistrar")
+ if err != nil {
+ return servLoc, err
+ }
+ }
+
+ // Create a new HTTP request to the the Service Registrar
+ mediaType := "application/json"
+ jsonQF, err := usecases.Pack(&newQuest, mediaType)
+ if err != nil {
+ return servLoc, err
+ }
+
+ srURL := ua.leadingRegistrar + "/query"
+ req, err := http.NewRequest(http.MethodPost, srURL, bytes.NewBuffer(jsonQF))
+ if err != nil {
+ return servLoc, err
+ }
+ req.Header.Set("Content-Type", mediaType)
+ req = req.WithContext(ctx)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ ua.leadingRegistrar = ""
+ return servLoc, err
+ }
+ defer resp.Body.Close()
+ respBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return servLoc, err
+ }
+ serviceListf, err := usecases.Unpack(respBytes, mediaType)
+ if err != nil {
+ return servLoc, err
+ }
+
+ serviceList, ok := serviceListf.(*forms.ServiceRecordList_v1)
+ if !ok {
+ return nil, fmt.Errorf("problem asserting the type of the service list form")
+ }
+
+ if len(serviceList.List) == 0 {
+ return nil, fmt.Errorf("unable to locate any such service: %s", newQuest.ServiceDefinition)
+ }
+
+ payload, err := json.MarshalIndent(serviceList, "", " ")
+ return payload, err
+}
diff --git a/orchestrator/thing_test.go b/orchestrator/thing_test.go
new file mode 100644
index 0000000..309271c
--- /dev/null
+++ b/orchestrator/thing_test.go
@@ -0,0 +1,241 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+func createTestServiceQuest() forms.ServiceQuest_v1 {
+ var ServiceQuest_v1_temperature forms.ServiceQuest_v1
+ ServiceQuest_v1_temperature.NewForm()
+ ServiceQuest_v1_temperature.ServiceDefinition = "temperature"
+ ServiceQuest_v1_temperature.Details = map[string][]string{"Unit": {"Celsius"}}
+ return ServiceQuest_v1_temperature
+}
+
+func (ua *UnitAsset) createDelayedBrokenURL(limit int) func() *http.Response {
+ count := 0
+ return func() *http.Response {
+ resp := &http.Response{
+ Status: "200 OK",
+ StatusCode: 200,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: nil,
+ }
+ count++
+ if count == limit {
+ f := createTestServiceRecordListForm()
+ ua.leadingRegistrar = brokenUrl
+ resp.Body = io.NopCloser(bytes.NewReader(f))
+ return resp
+ }
+ resp.Body = io.NopCloser(strings.NewReader(string("lead Service Registrar since")))
+ return resp
+ }
+}
+
+var emptyServiceRecordListForm forms.ServiceRecordList_v1
+
+func createEmptyServiceRecordListForm() []byte {
+ emptyServiceRecordListForm.NewForm()
+ fakebody, err := json.Marshal(emptyServiceRecordListForm)
+ if err != nil {
+ panic(fmt.Sprintf("Fail marshal at start of test: %v", err))
+ }
+ return fakebody
+}
+
+type getServiceURLTestStruct struct {
+ inputForm forms.ServiceQuest_v1
+ inputBody string
+ brokenUrl bool
+ writeError bool
+ mockTransportErr int
+ errHTTP error
+ expectedOutput string
+ expectedErr bool
+ testName string
+}
+
+var getServiceURLTestParams = []getServiceURLTestStruct{
+ {createTestServiceQuest(), string(createTestServiceRecordListForm()), false, false,
+ 0, nil, string(createTestServicePointForm()), false, "Good case, everything passes"},
+ {createTestServiceQuest(), string(createTestServiceRecordListForm()), false, false,
+ 2, errHTTP, "", true, "Bad case, DefaultClient.Do fails"},
+ {createTestServiceQuest(), string(createTestServiceRecordListForm()), false, true,
+ 0, nil, "", true, "Bad case, ReadAll fails"},
+ {createTestServiceQuest(), "hej hej", false, false,
+ 0, nil, "", true, "Bad case, Unpack fails"},
+ {createTestServiceQuest(), string(createTestServicePointForm()), false, false,
+ 0, nil, "", true, "Bad case, type assertion fails"},
+ {createTestServiceQuest(), string(createEmptyServiceRecordListForm()), false, false,
+ 0, nil, "", true, "Bad case, the service record list is empty"},
+}
+
+func TestGetServiceURL(t *testing.T) {
+ for _, testCase := range getServiceURLTestParams {
+ mua := createUnitAsset()
+ if mua == nil {
+ t.Fatalf("UAssets[\"Orchestration\"] is nil")
+ }
+ if testCase.brokenUrl == true {
+ newMockTransport(mua.createDelayedBrokenURL(2), testCase.mockTransportErr, testCase.errHTTP)
+ } else {
+ newMockTransport(createMultiHTTPResponse(2, testCase.writeError, testCase.inputBody),
+ testCase.mockTransportErr, testCase.errHTTP)
+ }
+ servLoc, err := mua.getServiceURL(testCase.inputForm)
+ if string(servLoc) != testCase.expectedOutput || (err == nil && testCase.expectedErr == true) ||
+ (err != nil && testCase.expectedErr == false) {
+ t.Errorf("In test case: %s: Expected %s and error %t, got: %s and %v",
+ testCase.testName, testCase.expectedOutput, testCase.expectedErr, string(servLoc), err)
+ }
+ }
+}
+
+func TestSelectService(t *testing.T) {
+ serviceListbytes := createTestServiceRecordListForm()
+ serviceListf, err := usecases.Unpack(serviceListbytes, "application/json")
+ if err != nil {
+ t.Fatalf("Error setting up test of SelectService function: %v", err)
+ }
+ serviceList, ok := serviceListf.(*forms.ServiceRecordList_v1)
+ if !ok {
+ t.Fatalf("Error in type assertion when setting up test of SelectService function")
+ }
+
+ expectedService := createTestServicePointForm()
+
+ receivedServicef := selectService(*serviceList)
+
+ receivedService, err := usecases.Pack(&receivedServicef, "application/json")
+ if err != nil {
+ t.Errorf("Expected the received service to be of type forms.ServicePoint_v1, got: %v", receivedService)
+ }
+
+ if string(expectedService) != string(receivedService) {
+ t.Errorf("Expected: %v, got: %v", expectedService, receivedService)
+ }
+}
+
+func createTestServiceRecordListFormWithSeveral() []byte {
+ var serviceRecordFormTemperature forms.ServiceRecord_v1
+ serviceRecordFormTemperature.NewForm()
+ serviceRecordFormTemperature.IPAddresses = []string{"123.456.789"}
+ serviceRecordFormTemperature.ProtoPort = map[string]int{"http": 123}
+ serviceRecordFormTemperature.ServiceDefinition = "temperature"
+ var serviceRecordFormRotation forms.ServiceRecord_v1
+ serviceRecordFormRotation.NewForm()
+ serviceRecordFormRotation.IPAddresses = []string{"123.456.789"}
+ serviceRecordFormRotation.ProtoPort = map[string]int{"http": 123}
+ serviceRecordFormRotation.ServiceDefinition = "rotation"
+ var ServiceRecordListFormWithSeveral forms.ServiceRecordList_v1
+ ServiceRecordListFormWithSeveral.NewForm()
+ ServiceRecordListFormWithSeveral.List = []forms.ServiceRecord_v1{serviceRecordFormTemperature,
+ serviceRecordFormRotation}
+ fakebody, err := json.MarshalIndent(ServiceRecordListFormWithSeveral, "", " ")
+ if err != nil {
+ panic(fmt.Sprintf("Fail marshal at start of test: %v", err))
+ }
+ return fakebody
+}
+
+func createTestServiceRecordListFormWithDefinition() []byte {
+ var serviceRecordFormWithDefinition forms.ServiceRecord_v1
+ serviceRecordFormWithDefinition.NewForm()
+ serviceRecordFormWithDefinition.IPAddresses = []string{"123.456.789"}
+ serviceRecordFormWithDefinition.ProtoPort = map[string]int{"http": 123}
+ serviceRecordFormWithDefinition.ServiceDefinition = "temperature"
+ var serviceRecordListFormWithDefinition forms.ServiceRecordList_v1
+ serviceRecordListFormWithDefinition.NewForm()
+ serviceRecordListFormWithDefinition.List = []forms.ServiceRecord_v1{serviceRecordFormWithDefinition}
+ fakebody, err := json.MarshalIndent(serviceRecordListFormWithDefinition, "", " ")
+ if err != nil {
+ panic(fmt.Sprintf("Fail marshal at start of test: %v", err))
+ }
+ return fakebody
+}
+
+func createTestServiceRecordListFormWithDetails() []byte {
+ var serviceRecordFormWithDetails forms.ServiceRecord_v1
+ serviceRecordFormWithDetails.NewForm()
+ serviceRecordFormWithDetails.IPAddresses = []string{"123.456.789"}
+ serviceRecordFormWithDetails.ProtoPort = map[string]int{"http": 123}
+ serviceRecordFormWithDetails.Details = map[string][]string{"Location": {"Kitchen"}}
+ var serviceRecordListFormWithDetails forms.ServiceRecordList_v1
+ serviceRecordListFormWithDetails.NewForm()
+ serviceRecordListFormWithDetails.List = []forms.ServiceRecord_v1{serviceRecordFormWithDetails}
+ fakebody, err := json.MarshalIndent(serviceRecordListFormWithDetails, "", " ")
+ if err != nil {
+ panic(fmt.Sprintf("Fail marshal at start of test: %v", err))
+ }
+ return fakebody
+}
+
+type getServicesURLTestStruct struct {
+ inputForm forms.ServiceQuest_v1
+ inputBody string
+ brokenUrl bool
+ writeError bool
+ mockTransportErr int
+ errHTTP error
+ expectedOutput string
+ expectedErr bool
+ testName string
+}
+
+var getServicesURLTestParams = []getServicesURLTestStruct{
+ {createTestServiceQuest(), string(createTestServiceRecordListFormWithSeveral()), false, false, 0, nil,
+ string(createTestServiceRecordListFormWithSeveral()), false,
+ "Good case, everything passes with several services"},
+ {createTestServiceQuest(), string(createTestServiceRecordListFormWithDefinition()), false, false, 0, nil,
+ string(createTestServiceRecordListFormWithDefinition()), false,
+ "Good case, everything passes with one service definition"},
+ {createTestServiceQuest(), string(createTestServiceRecordListFormWithDetails()), false, false, 0, nil,
+ string(createTestServiceRecordListFormWithDetails()), false,
+ "Good case, everything passes with one service details"},
+ {createTestServiceQuest(), string(createTestServiceRecordListForm()), false, false, 2, errHTTP,
+ "", true,
+ "Bad case, DefaultClient.Do fails"},
+ {createTestServiceQuest(), string(createTestServiceRecordListForm()), false, true, 0, nil,
+ "", true,
+ "Bad case, ReadAll fails"},
+ {createTestServiceQuest(), "hej hej", false, false, 0, nil,
+ "", true,
+ "Bad case, Unpack fails"},
+ {createTestServiceQuest(), string(createTestServicePointForm()), false, false, 0, nil,
+ "", true,
+ "Bad case, type assertion fails"},
+ {createTestServiceQuest(), string(createEmptyServiceRecordListForm()), false, false, 0, nil,
+ "", true,
+ "Bad case, the service record list is empty"},
+}
+
+func TestGetServicesURL(t *testing.T) {
+ for _, testCase := range getServicesURLTestParams {
+ mua := createUnitAsset()
+ if mua == nil {
+ t.Fatalf("UAssets[\"Orchestration\"] is nil")
+ }
+ if testCase.brokenUrl == true {
+ newMockTransport(mua.createDelayedBrokenURL(2), testCase.mockTransportErr, testCase.errHTTP)
+ } else {
+ newMockTransport(createMultiHTTPResponse(2, testCase.writeError, testCase.inputBody),
+ testCase.mockTransportErr, testCase.errHTTP)
+ }
+ servLoc, err := mua.getServicesURL(testCase.inputForm)
+ if string(servLoc) != testCase.expectedOutput || (err == nil && testCase.expectedErr == true) ||
+ (err != nil && testCase.expectedErr == false) {
+ t.Errorf("In test case: %s: Expected %s and error %t, got: %s and %v",
+ testCase.testName, testCase.expectedOutput, testCase.expectedErr, string(servLoc), err)
+ }
+ }
+}
diff --git a/parallax/parallax.go b/parallax/parallax.go
index a708b40..edce0bb 100644
--- a/parallax/parallax.go
+++ b/parallax/parallax.go
@@ -15,6 +15,7 @@ package main
import (
"context"
+ "crypto/x509/pkix"
"encoding/json"
"fmt"
"log"
@@ -39,6 +40,14 @@ func main() {
Details: map[string][]string{"Developer": {"Arrowhead"}},
ProtoPort: map[string]int{"https": 0, "http": 20151, "coap": 0},
InfoLink: "https://github.com/sdoque/systems/tree/main/parallax",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
}
// instantiate a template unit asset
@@ -47,20 +56,21 @@ func main() {
sys.UAssets[assetName] = &assetTemplate
// Configure the system
- rawResources, servsTemp, err := usecases.Configure(&sys)
+ rawResources, err := usecases.Configure(&sys)
if err != nil {
log.Fatalf("Configuration error: %v\n", err)
}
sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
- // Resources := make(map[string]*UnitAsset)
+ var cleanups []func()
for _, raw := range rawResources {
- var uac UnitAsset
+ var uac usecases.ConfigurableAsset
if err := json.Unmarshal(raw, &uac); err != nil {
log.Fatalf("Resource configuration error: %+v\n", err)
}
- ua, cleanup := newResource(uac, &sys, servsTemp)
- defer cleanup()
+ ua, cleanup := newResource(uac, &sys)
+ cleanups = append(cleanups, cleanup)
+ defer cleanup() // ensure cleanup is called when the program exits
sys.UAssets[ua.GetName()] = &ua
}
diff --git a/parallax/thing.go b/parallax/thing.go
index c702a59..91e1161 100644
--- a/parallax/thing.go
+++ b/parallax/thing.go
@@ -14,6 +14,7 @@
package main
import (
+ "encoding/json"
"fmt"
"log"
"time"
@@ -24,9 +25,16 @@ import (
"github.com/sdoque/mbaigo/components"
"github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
)
-//-------------------------------------Define the unit asset
+// -------------------------------------Define the unit asset
+// Traits are Asset-specific configurable parameters
+type Traits struct {
+ GpioPin gpio.PinIO `json:"-"`
+ position int `json:"-"`
+ dutyChan chan int `json:"-"`
+}
// UnitAsset type models the unit asset (interface) of the system
type UnitAsset struct {
@@ -36,9 +44,7 @@ type UnitAsset struct {
ServicesMap components.Services `json:"-"`
CervicesMap components.Cervices `json:"-"`
//
- GpioPin gpio.PinIO `json:"-"`
- position int `json:"-"`
- dutyChan chan int `json:"-"`
+ Traits
}
// GetName returns the name of the Resource.
@@ -61,6 +67,11 @@ func (ua *UnitAsset) GetDetails() map[string][]string {
return ua.Details
}
+// GetTraits returns the traits of the Resource.
+func (ua *UnitAsset) GetTraits() any {
+ return ua.Traits
+}
+
// ensure UnitAsset implements components.UnitAsset
var _ components.UnitAsset = (*UnitAsset)(nil)
@@ -80,7 +91,7 @@ func initTemplate() components.UnitAsset {
// var uat components.UnitAsset // this is an interface, which we then initialize
uat := &UnitAsset{
Name: "Servo_1",
- Details: map[string][]string{"Model": {"standard servo", "-90 to +90 degrees"}, "Location": {"Kitchen"}},
+ Details: map[string][]string{"Model": {"standard servo", "half_circle"}, "Location": {"Kitchen"}},
ServicesMap: components.Services{
rotation.SubPath: &rotation, // Inline assignment of the rotation service
},
@@ -91,16 +102,24 @@ func initTemplate() components.UnitAsset {
//-------------------------------------Instantiate the unit assets based on configuration
// newResource creates the Resource resource with its pointers and channels based on the configuration using the tConfig structs
-func newResource(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) {
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
// ua components.UnitAsset is an interface, which is implemented and initialized
ua := &UnitAsset{
- Name: uac.Name,
+ Name: configuredAsset.Name,
Owner: sys,
- Details: uac.Details,
- ServicesMap: components.CloneServices(servs),
- dutyChan: make(chan int),
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
+ }
+
+ traits, err := UnmarshalTraits(configuredAsset.Traits)
+ if err != nil {
+ log.Println("Warning: could not unmarshal traits:", err)
+ } else if len(traits) > 0 {
+ ua.Traits = traits[0] // or handle multiple traits if needed
}
+ ua.Traits.dutyChan = make(chan int)
+
// Initialize the periph.io host
if _, err := host.Init(); err != nil {
log.Fatalf("Failed to initialize periph: %v\n", err)
@@ -128,6 +147,19 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi
}
}
+// UnmarshalTraits unmarshals a slice of json.RawMessage into a slice of Traits.
+func UnmarshalTraits(rawTraits []json.RawMessage) ([]Traits, error) {
+ var traitsList []Traits
+ for _, raw := range rawTraits {
+ var t Traits
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal trait: %w", err)
+ }
+ traitsList = append(traitsList, t)
+ }
+ return traitsList, nil
+}
+
//-------------------------------------Unit asset's resource functions
// timing constants for the PWM (pulse width modulation)
@@ -142,7 +174,7 @@ const (
func (ua *UnitAsset) getPosition() (f forms.SignalA_v1a) {
f.NewForm()
f.Value = float64(ua.position)
- f.Unit = "percent"
+ f.Unit = "Percent"
f.Timestamp = time.Now()
return f
}
diff --git a/photographer/README.md b/photographer/README.md
new file mode 100644
index 0000000..0fc0d22
--- /dev/null
+++ b/photographer/README.md
@@ -0,0 +1,28 @@
+# # mbaigo System: Photographer
+
+The photographer system takes pictures using connected camera as a service.
+The take picture service stores the file locally and provides a link to get the file.
+
+## Compiling
+To compile the code, one needs to initialize the *go.mod* file with ``` go mod init github.com/sdoque/photographer``` before running *go mod tidy*.
+
+The reason the *go.mod* file is not included in the repository is that when developing the mbaigo module, a replace statement needs to be included to point to the development code.
+
+To run the code, one just needs to type in ```go run .``` within a terminal or at a command prompt.
+
+It is **important** to start the program from within it own directory (and each system should have their own directory) because it looks for its configuration file there. If it does not find it there, it will generate one and shutdown to allow the configuration file to be updated.
+
+The configuration and operation of the system can be verified using the system's web server using a standard web browser, whose address is provided by the system at startup.
+
+To build the software for one's own machine,
+```go build -o parallax_imac```, where the ending is used to clarify for which platform the code is for.
+
+
+## Cross compiling/building
+The following commands enable one to build for different platforms:
+- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o photographer_rpi64```
+
+One can find a complete list of platform by typing *go tool dist list* at the command prompt
+
+If one wants to secure copy it to a Raspberry pi,
+`scp parallax_rpi64 jan@192.168.1.6:Desktop/photographer/` where user is the *username* @ the *IP address* of the Raspberry Pi with a relative (to the user's home directory) target *Desktop/photographer/* directory.photographer
\ No newline at end of file
diff --git a/photographer/photographer.go b/photographer/photographer.go
new file mode 100644
index 0000000..b0c6f63
--- /dev/null
+++ b/photographer/photographer.go
@@ -0,0 +1,121 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Synecdoque
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The software is licensed under the MIT License. See the LICENSE file in this repository for details.
+ *
+ * Contributors:
+ * Jan A. van Deventer, Luleå - initial implementation
+ * Thomas Hedeler, Hamburg - initial implementation
+ ***************************************************************************SDG*/
+
+package main
+
+import (
+ "context"
+ "crypto/x509/pkix"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+func main() {
+ // prepare for graceful shutdown
+ ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled
+ defer cancel()
+
+ // instantiate the System
+ sys := components.NewSystem("photographer", ctx)
+
+ // instatiate the husk
+ sys.Husk = &components.Husk{
+ Description: " takes a picture using a camera and saves a file",
+ Details: map[string][]string{"Developer": {"Arrowhead"}},
+ ProtoPort: map[string]int{"https": 0, "http": 20160, "coap": 0},
+ InfoLink: "https://github.com/sdoque/mbaigo/tree/master/photographer",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
+ }
+
+ // instantiate a template unit asset
+ assetTemplate := initTemplate()
+ assetName := assetTemplate.GetName()
+ sys.UAssets[assetName] = &assetTemplate
+
+ // Configure the system
+ rawResources, err := usecases.Configure(&sys)
+ if err != nil {
+ log.Fatalf("Configuration error: %v\n", err)
+ }
+ sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
+ for _, raw := range rawResources {
+ var uac usecases.ConfigurableAsset
+ if err := json.Unmarshal(raw, &uac); err != nil {
+ log.Fatalf("Resource configuration error: %+v\n", err)
+ }
+ ua, cleanup := newResource(uac, &sys)
+ defer cleanup()
+ sys.UAssets[ua.GetName()] = &ua
+ }
+
+ // Generate PKI keys and CSR to obtain a authentication certificate from the CA
+ usecases.RequestCertificate(&sys)
+
+ // Register the (system) and its services
+ usecases.RegisterServices(&sys)
+
+ // start the requests handlers and servers
+ go usecases.SetoutServers(&sys)
+
+ // wait for shutdown signal, and gracefully close properly goroutines with context
+ <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal
+ fmt.Println("\nshuting down system", sys.Name)
+ cancel() // cancel the context, signaling the goroutines to stop
+ time.Sleep(3 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end
+}
+
+// Serving handles the resources services. NOTE: it expects those names from the request URL path
+func (ua *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath string) {
+ switch servicePath {
+ case "photograph":
+ ua.photograph(w, r)
+ case "files":
+ // return a 200 OK acknowledgment
+ w.WriteHeader(http.StatusOK)
+ fmt.Fprint(w, "OK")
+ default:
+ http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest)
+ }
+}
+
+func (ua *UnitAsset) photograph(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case "GET":
+ fileForm, err := ua.takePicture()
+ if err != nil {
+ log.Println(err)
+ http.Error(w, "Failed to take a picture, check if PiCam is connected", http.StatusNotFound)
+ return
+ }
+ usecases.HTTPProcessGetRequest(w, r, &fileForm)
+
+ default:
+ http.Error(w, "Method is not supported.", http.StatusNotFound)
+ }
+}
diff --git a/photographer/thing.go b/photographer/thing.go
new file mode 100644
index 0000000..20449db
--- /dev/null
+++ b/photographer/thing.go
@@ -0,0 +1,159 @@
+/*******************************************************************************
+ * Copyright (c) 2025 Synecdoque
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The software is licensed under the MIT License. See the LICENSE file in this repository for details.
+ *
+ * Contributors:
+ * Jan A. van Deventer, Luleå - initial implementation
+ * Thomas Hedeler, Hamburg - initial implementation
+ ***************************************************************************SDG*/
+
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "strconv"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+// -------------------------------------Define the unit asset
+// Traits are Asset-specific configurable parameters and variables
+type Traits struct {
+}
+
+// UnitAsset type models the unit asset (interface) of the system
+type UnitAsset struct {
+ Name string `json:"name"`
+ Owner *components.System `json:"-"`
+ Details map[string][]string `json:"details"`
+ ServicesMap components.Services `json:"-"`
+ CervicesMap components.Cervices `json:"-"`
+ Traits
+}
+
+// GetName returns the name of the Resource.
+func (ua *UnitAsset) GetName() string {
+ return ua.Name
+}
+
+// GetServices returns the services of the Resource.
+func (ua *UnitAsset) GetServices() components.Services {
+ return ua.ServicesMap
+}
+
+// GetCervices returns the list of consumed services by the Resource.
+func (ua *UnitAsset) GetCervices() components.Cervices {
+ return ua.CervicesMap
+}
+
+// GetDetails returns the details of the Resource.
+func (ua *UnitAsset) GetDetails() map[string][]string {
+ return ua.Details
+}
+
+// GetTraits returns the traits of the Resource.
+func (ua *UnitAsset) GetTraits() any {
+ return ua.Traits
+}
+
+// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation)
+var _ components.UnitAsset = (*UnitAsset)(nil)
+
+//-------------------------------------Instantiate a unit asset template
+
+// initTemplate initializes a UnitAsset with default values.
+func initTemplate() components.UnitAsset {
+ // Define the services that expose the capabilities of the unit asset(s)
+ photograph := components.Service{
+ Definition: "photograph",
+ SubPath: "photograph",
+ Details: map[string][]string{"Forms": {"jpeg_v1a"}},
+ Description: " takes a picture (GET) and saves it as a file",
+ }
+
+ // var uat components.UnitAsset // this is an interface, which we then initialize
+ uat := &UnitAsset{
+ Name: "PiCam",
+ Details: map[string][]string{"Model": {"PiCam v2"}, "Location": {"Entrance"}},
+ ServicesMap: components.Services{
+ photograph.SubPath: &photograph, // Inline assignment of the temperature service
+ },
+ }
+ return uat
+}
+
+//-------------------------------------Instantiate the unit assets based on configuration
+
+// newResource creates the Resource resource with its pointers and channels based on the configuration
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
+ ua := &UnitAsset{ // this a struct that implements the UnitAsset interface
+ Name: configuredAsset.Name,
+ Owner: sys,
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
+ }
+ traits, err := UnmarshalTraits(configuredAsset.Traits)
+ if err != nil {
+ log.Println("Warning: could not unmarshal traits:", err)
+ } else if len(traits) > 0 {
+ ua.Traits = traits[0] // or handle multiple traits if needed
+ }
+
+ return ua, func() {
+ log.Println("disconnecting from sensors")
+ }
+}
+
+// UnmarshalTraits unmarshals a slice of json.RawMessage into a slice of Traits.
+func UnmarshalTraits(rawTraits []json.RawMessage) ([]Traits, error) {
+ var traitsList []Traits
+ for _, raw := range rawTraits {
+ var t Traits
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal trait: %w", err)
+ }
+ traitsList = append(traitsList, t)
+ }
+ return traitsList, nil
+}
+
+//-------------------------------------Unit asset's resource functions
+
+func (ua *UnitAsset) takePicture() (f forms.FileForm_v1, err error) {
+ // Ensure the ./pictures directory exists
+ err = os.MkdirAll("./files", os.ModePerm)
+ if err != nil {
+ return f, fmt.Errorf("failed to create directory: %w", err)
+ }
+
+ // Format the timestamp and create the filename with the ./pictures directory prefix
+ timestamp := time.Now().Format("20060102-150405")
+ filename := fmt.Sprintf("/files/image_%s.jpg", timestamp)
+
+ // Command to take a picture
+ cmd := exec.Command("libcamera-still", "-o", "."+filename)
+
+ // Execute the command
+ if err := cmd.Run(); err != nil {
+ return f, fmt.Errorf("failed to take picture: %w", err)
+ }
+ urlPath := "http://" + ua.Owner.Host.IPAddresses[0] + ":" + strconv.Itoa(int(ua.Owner.Husk.ProtoPort["http"])) + "/" + ua.Owner.Name + "/" + ua.Name
+ f.NewForm()
+ f.FileURL = urlPath + filename
+ f.Timestamp = time.Now()
+ return f, nil
+}
diff --git a/revolutionary/README.md b/revolutionary/README.md
new file mode 100644
index 0000000..258dd40
--- /dev/null
+++ b/revolutionary/README.md
@@ -0,0 +1,81 @@
+# mbaigo System: revolutionary
+
+## Purpose
+The revolutionary system runs on [Revolution Pi Connect 4](https://revolutionpi.com/documentation/revpi-connect-4/) PLC with an analogue input/output module within a pump station demonstrator.
+It measures the level of two upper tanks (-10 to +10 Volts) and outputs a control signal to the pump (0 to +10 Volts).
+
+
+## Sequence of events
+This system offers three services: the two level sensors and the pump speed signal.
+For the services to be discoverable, they must be in the registry of currently available services.
+The consuming system asks the Orchestrator for the desired service by describing it.
+The orchestrator inquires with the Service Registrar if the service is available and if so provides the service URL.
+The consuming system will request the service directly from the Revolutionary system via this URL until it stops working.
+
+```mermaid
+sequenceDiagram
+
+participant ServiceRegistrar as ServiceRegistrar
+participant Orchestrator as Orchestrator
+participant Revolutionary as Revolutionary
+participant ArrowheadSystem as Any Arrowhead System
+
+loop Before registration expiration
+ activate Revolutionary
+ Revolutionary->>+ServiceRegistrar: Register each service
+ ServiceRegistrar-->>-Revolutionary: New expiration time
+ deactivate Revolutionary
+end
+
+
+loop Every x period
+ activate ArrowheadSystem
+
+ alt Service location is unknown
+ ArrowheadSystem->>+Orchestrator: Discover service provider
+ activate Orchestrator
+ Orchestrator->>+ServiceRegistrar: Query for service
+ ServiceRegistrar-->>-Orchestrator: Return service location
+ Orchestrator-->>ArrowheadSystem: Forward service location
+ deactivate Orchestrator
+ else Service location is known
+ Note over ArrowheadSystem: Use cached location
+ end
+
+ loop For each wanted service
+ ArrowheadSystem->>Revolutionary: Get statistics from service
+ activate Revolutionary
+ Revolutionary-->>ArrowheadSystem: Return latest data
+ deactivate Revolutionary
+ ArrowheadSystem->>ArrowheadSystem: Cache sampled data
+ end
+
+ deactivate ArrowheadSystem
+end
+```
+
+## Configuration
+The default configuration using the [Rev Pi AIO](https://revolutionpi.com/documentation/revpi-aio/) module is the upper tank level sensor (v+ @ pin 28, GND @ pin 24). Its name is **InputValue_1**
+
+At deploment, the two other unit assets need to be provided (into the array of unit assets in the *systemconfig.json* file):
+- the middle tank level sensor (v+ @ pin 17, GND @ pin 23) has the name **InputValue_1**.
+- The pump signal (v+ @ pin 1 with GND @ pin 5) has the name **OutputValue_1**.
+
+## Compiling
+To compile the code, one needs to get the mbaigo module
+```go get github.com/sdoque/mbaigo```
+and initialize the *go.mod* file with ``` go mod init github.com/sdoque/systems/Revolutionary``` before running *go mod tidy*.
+
+To run the code, one just needs to type in ```go run .``` within a terminal or at a command prompt. (But that works only on a Revolution Pi, since it is accessing that hardware.)
+
+It is **important** to start the program from within its own directory (and each system should have their own directory) because program looks for its configuration file there. If it does not find it there, it will generate one and shutdown to allow the configuration file to be updated.
+
+The configuration and operation of the system can be verified using the system's web server using a standard web browser, whose address is provided by the system at startup.
+
+## Cross compiling/building
+The following commands enable one to build for a different platform:
+
+- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o revolutionary_rpi64```
+
+If one wants to secure copy it to a Revolution pi,
+```scp revolutionary_rpi64 pi@192.168.1.9:station/revolutionary/``` where user is the *pi* @ the *IP address* of the Raspberry Pi with a relative (to the user's home directory) destination (the *station/Revolutionary/* directory in this case).
diff --git a/revolutionary/revolutionary.go b/revolutionary/revolutionary.go
new file mode 100644
index 0000000..27071fb
--- /dev/null
+++ b/revolutionary/revolutionary.go
@@ -0,0 +1,170 @@
+/*******************************************************************************
+ * Copyright (c) 2024 Synecdoque
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The software is licensed under the MIT License. See the LICENSE file in this repository for details.
+ *
+ * Contributors:
+ * Jan A. van Deventer, Luleå - initial implementation
+ * Thomas Hedeler, Hamburg - initial implementation
+ ***************************************************************************SDG*/
+
+package main
+
+import (
+ "context"
+ "crypto/x509/pkix"
+ "encoding/json"
+ "io"
+ "log"
+ "mime"
+ "net/http"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+func main() {
+ // prepare for graceful shutdown
+ ctx, cancel := context.WithCancel(context.Background()) // create a context that can be cancelled
+ defer cancel() // make sure all paths cancel the context to avoid context leak
+
+ // instantiate the System
+ sys := components.NewSystem("revolutionary", ctx)
+
+ // instantiate the husk
+ sys.Husk = &components.Husk{
+ Description: "interacts with the RevPi Connect 4 PLC",
+ Details: map[string][]string{"Developer": {"Synecdoque"}},
+ ProtoPort: map[string]int{"https": 0, "http": 20153, "coap": 0},
+ InfoLink: "https://github.com/sdoque/systems/tree/main/revolutionary",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
+ }
+
+ // instantiate a template unit asset
+ assetTemplate := initTemplate()
+ assetName := assetTemplate.GetName()
+ sys.UAssets[assetName] = &assetTemplate
+
+ // Configure the system
+ rawResources, err := usecases.Configure(&sys)
+ if err != nil {
+ log.Fatalf("configuration error: %v\n", err)
+ }
+ sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
+ var cleanups []func()
+ for _, raw := range rawResources {
+ var uac usecases.ConfigurableAsset
+ if err := json.Unmarshal(raw, &uac); err != nil {
+ log.Fatalf("resource configuration error: %+v\n", err)
+ }
+ ua, cleanup := newResource(uac, &sys)
+ cleanups = append(cleanups, cleanup)
+ // defer cleanup()
+ sys.UAssets[ua.GetName()] = &ua
+ }
+
+ // Generate PKI keys and CSR to obtain a authentication certificate from the CA
+ usecases.RequestCertificate(&sys)
+
+ // Register the (system) and its services
+ usecases.RegisterServices(&sys)
+
+ // start the requests handlers and servers
+ go usecases.SetoutServers(&sys)
+
+ // wait for shutdown signal, and gracefully close properly goroutines with context
+ <-sys.Sigs // wait for a SIGINT (Ctrl+C) signal
+ log.Println("\nshuting down system", sys.Name)
+ cancel() // cancel the context, signaling the goroutines to stop
+ for _, cleanup := range cleanups {
+ cleanup()
+ }
+ time.Sleep(2 * time.Second) // allow the go routines to be executed, which might take more time than the main routine to end
+}
+
+// Serving handles the resources services. NOTE: it expects those names from the request URL path
+func (ua *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath string) {
+ switch servicePath {
+ case "access":
+ ua.access(w, r)
+ default:
+ http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest)
+ }
+}
+
+// access gets the unit asset's AIO channel datum and sends it in a signal form
+func (ua *UnitAsset) access(w http.ResponseWriter, r *http.Request) {
+
+ switch r.Method {
+ case http.MethodGet:
+ // Prepare a fresh tray for this request
+ requestTray := ServiceTray{
+ SampledDatum: make(chan forms.SignalA_v1a),
+ Error: make(chan error),
+ }
+ ua.serviceChannel <- requestTray
+ select {
+ case err := <-requestTray.Error:
+ log.Printf("Logic error in getting measurement: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ case signalForm := <-requestTray.SampledDatum:
+ usecases.HTTPProcessGetRequest(w, r, &signalForm)
+ return
+ case <-time.After(5 * time.Second):
+ http.Error(w, "Request timed out", http.StatusGatewayTimeout)
+ log.Println("Timeout on GET access")
+ return
+ }
+
+ case http.MethodPost, http.MethodPut:
+ // Unpack the incoming form
+ log.Printf("Unpacking output signal form for %s", ua.Name)
+ contentType := r.Header.Get("Content-Type")
+ mediaType, _, err := mime.ParseMediaType(contentType)
+ if err != nil {
+ log.Printf("Error parsing media type: %v", err)
+ http.Error(w, "Unsupported Media Type", http.StatusUnsupportedMediaType)
+ return
+ }
+ bodyBytes, err := io.ReadAll(r.Body)
+ if err != nil {
+ log.Printf("Error reading request body: %v", err)
+ http.Error(w, "Bad request", http.StatusBadRequest)
+ return
+ }
+ serviceReq, err := usecases.Unpack(bodyBytes, mediaType)
+ if err != nil {
+ log.Printf("Error unpacking output signal form: %v", err)
+ http.Error(w, "Bad request", http.StatusBadRequest)
+ return
+ }
+ outputForm, ok := serviceReq.(*forms.SignalA_v1a) // Ensure the form is of the expected type
+ if !ok {
+ log.Println("Unexpected form type in access")
+ http.Error(w, "Bad request", http.StatusBadRequest)
+ return
+ }
+
+ ua.outputChannel <- outputForm.Value // Send the value to the output channel for processing
+ w.WriteHeader(http.StatusOK) // Respond with 200 OK if the write is successful
+
+ default:
+ http.Error(w, "Method not supported", http.StatusMethodNotAllowed)
+ }
+}
diff --git a/revolutionary/thing.go b/revolutionary/thing.go
new file mode 100644
index 0000000..80777fe
--- /dev/null
+++ b/revolutionary/thing.go
@@ -0,0 +1,284 @@
+/*******************************************************************************
+ * Copyright (c) 2024 Synecdoque
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, subject to the following conditions:
+ *
+ * The software is licensed under the MIT License. See the LICENSE file in this repository for details.
+ *
+ * Contributors:
+ * Jan A. van Deventer, Luleå - initial implementation
+ * Thomas Hedeler, Hamburg - initial implementation
+ ***************************************************************************SDG*/
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/forms"
+ "github.com/sdoque/mbaigo/usecases"
+)
+
+// Define the types of requests the measurement manager can handle
+type ServiceTray struct {
+ SampledDatum chan forms.SignalA_v1a
+ Error chan error
+}
+
+// -------------------------------------Define the unit asset
+// Traits are Asset-specific configurable parameters
+type Traits struct {
+ Address string `json:"address"` // Address of the IO
+ Value float64 `json:"value"` // Start up value of the IO
+ MinValue float64 `json:"minValue"` // Minimum value of the IO
+ MaxValue float64 `json:"maxValue"` // Maximum value of the IO
+}
+
+// UnitAsset type models the unit asset (interface) of the system.
+type UnitAsset struct {
+ Name string `json:"name"`
+ Owner *components.System `json:"-"`
+ Details map[string][]string `json:"details"`
+ ServicesMap components.Services `json:"-"`
+ CervicesMap components.Cervices `json:"-"`
+ // Asset-specific parameters
+ Traits
+ tStamp time.Time `json:"-"`
+ serviceChannel chan ServiceTray `json:"-"` // Add a channel for signal reading
+ outputChannel chan float64 `json:"-"` // Channel for output signals
+}
+
+// GetName returns the name of the Resource.
+func (ua *UnitAsset) GetName() string {
+ return ua.Name
+}
+
+// GetServices returns the services of the Resource.
+func (ua *UnitAsset) GetServices() components.Services {
+ return ua.ServicesMap
+}
+
+// GetCervices returns the list of consumed services by the Resource.
+func (ua *UnitAsset) GetCervices() components.Cervices {
+ return ua.CervicesMap
+}
+
+// GetDetails returns the details of the Resource.
+func (ua *UnitAsset) GetDetails() map[string][]string {
+ return ua.Details
+}
+
+// GetTraits returns the traits of the Resource.
+func (ua *UnitAsset) GetTraits() any {
+ return ua.Traits
+}
+
+// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation)
+var _ components.UnitAsset = (*UnitAsset)(nil)
+
+//-------------------------------------Instantiate a unit asset template
+
+// initTemplate initializes a UnitAsset with default values.
+func initTemplate() components.UnitAsset {
+ // Define the services that expose the capabilities of the unit asset(s)
+ access := components.Service{
+ Definition: "level",
+ SubPath: "access",
+ Details: map[string][]string{"Forms": {"SignalA_v1a"}},
+ RegPeriod: 30,
+ Description: "reads the input (GET) or changes the output (POST) of the channel",
+ }
+
+ // var uat components.UnitAsset // this is an interface, which we then initialize
+ uat := &UnitAsset{
+ Name: "LevelSensor_1",
+ Details: map[string][]string{"Unit": {"Percent"}, "Location": {"UpperTank"}, "Description": {"level"}},
+ Traits: Traits{
+ Address: "InputValue_1", // Default address for the Rev Pi AIO channel
+ Value: 0.0, // Default value for the output
+ },
+ ServicesMap: components.Services{
+ access.SubPath: &access, // add the service to the map
+ },
+ }
+ return uat
+}
+
+//-------------------------------------Instantiate the unit assets based on configuration
+
+// newResource creates the Resource resource with its pointers and channels based on the configuration
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
+ ua := &UnitAsset{ // this a struct that implements the UnitAsset interface
+ Name: configuredAsset.Name,
+ Owner: sys,
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
+ serviceChannel: make(chan ServiceTray), // Initialize the channel
+ outputChannel: make(chan float64), // Initialize the output channel
+ }
+
+ traits, err := UnmarshalTraits(configuredAsset.Traits)
+ if err != nil {
+ log.Println("Warning: could not unmarshal traits:", err)
+ } else if len(traits) > 0 {
+ ua.Traits = traits[0] // or handle multiple traits if needed
+ }
+
+ // start the unit asset(s)
+ go ua.sampleSignal(sys.Ctx)
+
+ return ua, func() {
+ log.Printf("disconnecting from %s\n", ua.Name)
+ // close(ua.outputChannel) // Ensure the output channel is closed when the goroutine exits
+ // close(ua.serviceChannel) // Ensure the channel is closed when the goroutine exits
+ }
+}
+
+// UnmarshalTraits unmarshals a slice of json.RawMessage into a slice of Traits.
+func UnmarshalTraits(rawTraits []json.RawMessage) ([]Traits, error) {
+ var traitsList []Traits
+ for _, raw := range rawTraits {
+ var t Traits
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal trait: %w", err)
+ }
+ traitsList = append(traitsList, t)
+ }
+ return traitsList, nil
+}
+
+//-------------------------------------Unit asset's functionalities
+
+// sampleSignal obtains the temperature from respective Rev Pi AIO resource at regular intervals
+func (ua *UnitAsset) sampleSignal(ctx context.Context) {
+ // Create a ticker that triggers every second
+ ticker := time.NewTicker(1 * time.Second)
+ defer ticker.Stop() // Clean up the ticker when done
+
+ sigChan := make(chan float64) // Channel for latest signal readings
+ tStampChan := make(chan time.Time)
+
+ // Start a separate goroutine for signal reading
+ go func() {
+ for {
+ select {
+ case <-ctx.Done(): // Stop when the context is canceled
+ os.Exit(0)
+ return
+
+ case <-ticker.C: // sample the signal at regular intervals
+ v, err := readInputVoltage(ua.Address)
+ if err != nil {
+ fmt.Println("Read error:", err)
+ } else {
+ fmt.Printf("%s = %.2f V\n", ua.Name, v/1000)
+ }
+ nv := NormalizeToPercent(v, ua.MinValue, ua.MaxValue) // Normalize the value to a percentage
+
+ // Send the sampled signal and timestamp back to the main loop
+ select {
+ case sigChan <- nv:
+ tStampChan <- time.Now()
+ case <-ctx.Done(): // Stop the goroutine if context is canceled
+ return
+ }
+ }
+ }
+ }()
+
+ for {
+ select {
+ case sigValue := <-sigChan: // Update signal value and timestamp
+ ua.Value = sigValue
+ ua.tStamp = <-tStampChan
+ case order := <-ua.serviceChannel:
+ // switch order.Action {
+ // case "read":
+ // Send the latest signal value and timestamp to the channel
+ var f forms.SignalA_v1a
+ f.NewForm()
+ f.Value = ua.Value
+ f.Unit = "Percent"
+ f.Timestamp = ua.tStamp
+ order.SampledDatum <- f
+ case requestedOutup := <-ua.outputChannel:
+ log.Printf("Received output request for %s: %.2f%%\n", ua.Name, requestedOutup)
+ rawValue := PercentToRaw(requestedOutup)
+ log.Printf("Converted output value to raw: %d\n", rawValue)
+ err := writeOutput(ua.Address, rawValue)
+ if err != nil {
+ fmt.Printf("Error writing output: %v\n", err)
+ return
+ }
+ }
+ }
+}
+
+// readInput reads the input value from the piTest command line tool.
+func readInputVoltage(varName string) (float64, error) {
+ fmt.Println("Reading input:", varName)
+ cmd := exec.Command("/usr/bin/piTest", "-1", "-q", "-r", varName)
+ cmd.Stderr = os.Stderr
+ reading, err := cmd.Output()
+ if err != nil {
+ return 0, fmt.Errorf("reading the Rev Pi failed: %w", err)
+ }
+
+ valueStr := strings.TrimSpace(string(reading))
+ raw, err := strconv.Atoi(valueStr)
+ if err != nil {
+ return 0, fmt.Errorf("invalid raw value: %w", err)
+ }
+
+ voltage := float64(raw) // the raw value is in millivolts, convert to volts
+ return voltage, nil
+}
+
+// writeOutput writes the output value to the piTest command line tool.
+func writeOutput(varName string, value int) error {
+ fmt.Printf("Writing %d to %s\n", value, varName)
+ cmd := exec.Command("/usr/bin/piTest", "-w", fmt.Sprintf("%s,%d", varName, value))
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
+
+// PercentToRaw converts a percentage (0–100%) to a raw 16-bit value for the piTest tool.
+func PercentToRaw(percent float64) int {
+ if percent < 0 {
+ percent = 0
+ }
+ if percent > 100 {
+ percent = 100
+ }
+ return int(percent * 100.0)
+}
+
+// NormalizeToPercent normalizes a reading to a percentage based on the provided min and max values.
+func NormalizeToPercent(reading, min, max float64) float64 {
+ // if max == min {
+ // return 0 // or return NaN/error to avoid division by zero
+ // }
+ percent := reading / 100 //* (reading - min) / (max - min)
+
+ // Clamp to [0, 100] in case reading is outside the expected range
+ if percent < 0 {
+ return 0
+ }
+ if percent > 100 {
+ return 100
+ }
+ return percent
+}
diff --git a/telegrapher/telegrapher.go b/telegrapher/telegrapher.go
index 1f6a825..aba525d 100644
--- a/telegrapher/telegrapher.go
+++ b/telegrapher/telegrapher.go
@@ -1,5 +1,5 @@
/*******************************************************************************
- * Copyright (c) 2024 Synecdoque
+ * Copyright (c) 2025 Synecdoque
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
@@ -18,6 +18,7 @@ package main
import (
"context"
+ "crypto/x509/pkix"
"encoding/json"
"fmt"
"log"
@@ -36,12 +37,20 @@ func main() {
// instantiate the System
sys := components.NewSystem("telegrapher", ctx)
- // instatiate the husk
+ // instantiate the husk
sys.Husk = &components.Husk{
- Description: " subcribes and publishes to an MQTT broker",
+ Description: " subscribes and publishes to an MQTT broker",
Details: map[string][]string{"Developer": {"Synecdoque"}},
ProtoPort: map[string]int{"https": 0, "http": 20172, "coap": 0},
InfoLink: "https://github.com/sdoque/systems/tree/main/telegrapher",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
}
// instantiate a template unit asset
@@ -50,23 +59,19 @@ func main() {
sys.UAssets[assetName] = &assetTemplate
// Configure the system
- rawResources, servsTemp, err := usecases.Configure(&sys)
+ rawResources, err := usecases.Configure(&sys)
if err != nil {
log.Fatalf("Configuration error: %v\n", err)
}
-
sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
- // Resources := make(map[string]*UnitAsset)
for _, raw := range rawResources {
- var uac UnitAsset
+ var uac usecases.ConfigurableAsset
if err := json.Unmarshal(raw, &uac); err != nil {
log.Fatalf("Resource configuration error: %+v\n", err)
}
- promUA, cleanup := newResource(uac, &sys, servsTemp)
+ ua, cleanup := newResource(uac, &sys)
defer cleanup()
- for _, nua := range promUA {
- sys.UAssets[nua.GetName()] = &nua
- }
+ sys.UAssets[ua.GetName()] = &ua
}
// Generate PKI keys and CSR to obtain a authentication certificate from the CA
@@ -91,15 +96,15 @@ func (ua *UnitAsset) Serving(w http.ResponseWriter, r *http.Request, servicePath
if svrs[servicePath] != nil {
ua.access(w, r, servicePath)
} else {
- http.Error(w, "Invalid service request [Do not modify the services subpath in the configurration file]", http.StatusBadRequest)
+ http.Error(w, "Invalid service request [Do not modify the services subpath in the configuration file]", http.StatusBadRequest)
}
}
func (ua *UnitAsset) access(w http.ResponseWriter, r *http.Request, servicePath string) {
switch r.Method {
case "GET":
- msg := messageList[ua.metatopic+"/"+servicePath]
- if msg != nil {
+ msg := ua.Message
+ if len(msg) > 0 {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
w.Write(msg)
@@ -107,11 +112,24 @@ func (ua *UnitAsset) access(w http.ResponseWriter, r *http.Request, servicePath
http.Error(w, "The subscribed topic is not being published", http.StatusBadRequest)
}
case "PUT":
- // sig, err := usecases.HTTPProcessSetRequest(w, r)
+ // data, err := io.ReadAll(r.Body)
// if err != nil {
- // log.Println("Error with the setting request of the position ", err)
+ // http.Error(w, "Failed to read request body", http.StatusBadRequest)
+ // return
// }
- // ua.setPosition(sig)
+ // defer r.Body.Close()
+
+ // if err := ua.publishRaw(data); err != nil {
+ log.Printf("MQTT client is connected: %v", ua.mClient.IsConnected())
+
+ if err := ua.publishRaw([]byte(`{"test":123}`)); err != nil {
+ log.Printf("Failed to publish: %v", err)
+ http.Error(w, "MQTT publish failed", http.StatusInternalServerError)
+ return
+ }
+ log.Printf("MQTT client is connected: %v", ua.mClient.IsConnected())
+
+ w.WriteHeader(http.StatusAccepted)
default:
http.Error(w, "Method is not supported.", http.StatusNotFound)
}
diff --git a/telegrapher/thing.go b/telegrapher/thing.go
index 6940ead..5a2c4ff 100644
--- a/telegrapher/thing.go
+++ b/telegrapher/thing.go
@@ -17,12 +17,14 @@
package main
import (
+ "encoding/json"
"fmt"
"log"
"strings"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/sdoque/mbaigo/components"
+ "github.com/sdoque/mbaigo/usecases"
)
// Define your global variable
@@ -33,25 +35,28 @@ func init() {
messageList = make(map[string][]byte)
}
-//-------------------------------------Define the unit asset
+// -------------------------------------Define the unit asset
+// Traits are Asset-specific configurable parameters and variables
+type Traits struct {
+ Broker string `json:"broker"`
+ mClient mqtt.Client `json:"-"`
+ Pattern []string `json:"pattern"`
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Period int `json:"period"` // Period is the time interval for periodic service consumption, e.g., 30 seconds
+ Topic string `json:"-"` // Topic is the MQTT topic to which the unit asset subscribes
+ Message []byte `json:"-"`
+}
// UnitAsset type models the unit asset (interface) of the system
type UnitAsset struct {
- Name string `json:"name"`
+ Name string `json:"topic"`
Owner *components.System `json:"-"`
Details map[string][]string `json:"details"`
ServicesMap components.Services `json:"-"`
CervicesMap components.Cervices `json:"-"`
//
- Broker string `json:"broker"`
- Topics []string `json:"topics"`
- Pattern []string `json:"pattern"`
- Username string `json:"username"`
- Password string `json:"password"`
- client mqtt.Client
- topic string
- serviceDef string
- metatopic string
+ Traits
}
// GetName returns the name of the Resource.
@@ -74,6 +79,11 @@ func (ua *UnitAsset) GetDetails() map[string][]string {
return ua.Details
}
+// GetTraits returns the traits of the Resource.
+func (ua *UnitAsset) GetTraits() any {
+ return ua.Traits
+}
+
// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation)
var _ components.UnitAsset = (*UnitAsset)(nil)
@@ -83,24 +93,28 @@ var _ components.UnitAsset = (*UnitAsset)(nil)
func initTemplate() components.UnitAsset {
// Define the services that expose the capabilities of the unit asset(s)
access := components.Service{
- Definition: "access",
+ Definition: "temperature",
SubPath: "access",
Details: map[string][]string{"forms": {"payload"}},
RegPeriod: 30,
Description: "Read the current topic message (GET) or publish to it (PUT)",
}
+ assetTraits := Traits{
+ Broker: "tcp://localhost:1883",
+ Username: "user",
+ Password: "password",
+ // Topic: "kitchen/temperature", // Default topics
+ Pattern: []string{"Room"}, // Default patterns e.g. "House", "Room" as in "MyHouse/Kitchen"
+ }
+
uat := &UnitAsset{
- Name: "MQTT Broker",
+ Name: "Kitchen/temperature",
Details: map[string][]string{"mqtt": {"home"}},
+ Traits: assetTraits,
ServicesMap: components.Services{
access.SubPath: &access,
},
- Broker: "tcp://localhost:1883",
- Username: "user",
- Password: "password",
- Topics: []string{"kitchen/temperature", "topic2", "topic3"}, // Default topics
- Pattern: []string{"pattern1", "pattern2", "pattern3"}, // Default patterns
}
return uat
}
@@ -108,22 +122,89 @@ func initTemplate() components.UnitAsset {
//-------------------------------------Instantiate the unit assets based on configuration
// newResource creates the Resource resource with its pointers and channels based on the configuration using the tConig structs
-func newResource(uac UnitAsset, sys *components.System, servs []components.Service) ([]components.UnitAsset, func()) {
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
+ topic := configuredAsset.Name
+ assetName := strings.ReplaceAll(topic, "/", "_")
+ // instantiate the unit asset
+ ua := &UnitAsset{
+ Name: assetName,
+ Owner: sys,
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
+ }
+
+ traits, err := UnmarshalTraits(configuredAsset.Traits)
+ if err != nil {
+ log.Println("Warning: could not unmarshal traits:", err)
+ } else if len(traits) > 0 {
+ ua.Traits = traits[0] // or handle multiple traits if needed
+ }
+
+ if len(ua.Pattern) > 0 {
+ lastSlashIndex := strings.LastIndex(topic, "/")
+ if lastSlashIndex == -1 {
+ fmt.Printf("topic %s has no forward slash and is ignored\n", topic)
+ return nil, func() {}
+ }
+ a := topic[:lastSlashIndex]
+ s := topic[lastSlashIndex+1:]
+
+ // Fill Details from pattern and topic
+ metaDetails := strings.Split(a, "/")
+ if ua.Details == nil {
+ ua.Details = make(map[string][]string)
+ }
+ for i := 0; i < len(ua.Pattern) && i < len(metaDetails); i++ {
+ ua.Details[ua.Pattern[i]] = append(ua.Details[ua.Pattern[i]], metaDetails[i])
+ }
+
+ access := components.Service{
+ Definition: s,
+ SubPath: "access",
+ Details: map[string][]string{"forms": {"mqttPayload"}},
+ RegPeriod: 30,
+ Description: "Read the current topic message (GET) or publish to it (PUT)",
+ }
+ ua.ServicesMap[access.SubPath] = &access
+
+ // If the unit asset has a period defined, instantiate the consumed services
+ // if ua.Period > 0 {
+ // sProtocols := components.SProtocols(sys.Husk.ProtoPort)
+ // t := &components.Cervice{
+ // Definition: s,
+ // Protos: sProtocols,
+ // Nodes: make(map[string][]string), // ✅ Corrected
+ // }
+ // if ua.CervicesMap == nil {
+ // ua.CervicesMap = make(components.Cervices)
+ // }
+ // ua.CervicesMap[t.Definition] = t
+ // }
+
+ }
+
// Create MQTT client options
opts := mqtt.NewClientOptions()
- opts.AddBroker(uac.Broker)
- opts.SetUsername(uac.Username)
- opts.SetPassword(uac.Password)
+ opts.AddBroker(ua.Broker)
+ if ua.Username != "" { // Password can be empty string for some brokers
+ opts.SetUsername(ua.Username)
+ opts.SetPassword(ua.Password)
+ }
+ opts.SetConnectionLostHandler(func(client mqtt.Client, err error) {
+ log.Printf("Connection lost: %v", err)
+ })
+ opts.SetOnConnectHandler(func(client mqtt.Client) {
+ log.Println("MQTT connection established")
+ })
// Create and start the MQTT client
- mClient := mqtt.NewClient(opts)
- if token := mClient.Connect(); token.Wait() && token.Error() != nil {
+ log.Println("Connecting to broker:", ua.Traits.Broker)
+ ua.mClient = mqtt.NewClient(opts)
+ if token := ua.mClient.Connect(); token.Wait() && token.Error() != nil {
log.Fatalf("Error connecting to MQTT broker: %v", token.Error())
}
- fmt.Println("Connected to MQTT broker")
- assetList := []components.UnitAsset{}
- assetMap := make(map[string]components.UnitAsset) // Map asset names to UnitAssets
+ log.Println("Connected to MQTT broker")
// Define the message handler callback
messageHandler := func(client mqtt.Client, msg mqtt.Message) {
@@ -133,88 +214,102 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi
if messageList == nil {
messageList = make(map[string][]byte)
}
+ ua.Message = msg.Payload() // Assign message to topic in the map
+ }
- messageList[msg.Topic()] = msg.Payload() // Assign message to topic in the map
+ // Subscribe to the topic
+ if token := ua.mClient.Subscribe(topic, 0, messageHandler); token.Wait() && token.Error() != nil {
+ log.Fatalf("Error subscribing to topic: %v", token.Error())
}
+ fmt.Printf("Subscribed to topic: %s\n", topic)
- for _, topicItem := range uac.Topics {
- // Consider the last term of a topic to be a service, and the preceding part is the asset
- lastSlashIndex := strings.LastIndex(topicItem, "/")
- if lastSlashIndex == -1 {
- fmt.Printf("topic %s has no forward slash and is ignored\n", topicItem)
- continue
- }
- a := topicItem[:lastSlashIndex] // The asset part
- s := topicItem[lastSlashIndex+1:] // The service part
- aName := strings.ReplaceAll(a, "/", "_")
+ // Periodically publish a message to the topic
+ // if ua.Period > 0 {
+ // go func(ua *UnitAsset) {
+ // ticker := time.NewTicker(time.Duration(ua.Period) * time.Second)
+ // defer ticker.Stop()
+ // for {
+ // select {
+ // case <-ticker.C:
+ // payload := map[string]interface{}{
+ // "value": 0,
+ // "unit": "celsius",
+ // "timestamp": time.Now().Format(time.RFC3339),
+ // "version": "SignalA_v1.0",
+ // }
+ // if err := ua.publishToTopic(payload, "application/json"); err != nil {
+ // log.Printf("Periodic publish failed for topic %s: %v", ua.Topic, err)
+ // } else {
+ // log.Printf("Periodic message sent to topic %s", ua.Topic)
+ // }
+ // case <-sys.Ctx.Done():
+ // log.Printf("Stopping periodic publishing for %s", ua.Topic)
+ // return
+ // }
+ // }
+ // }(ua)
+ // }
- // Redefine the service
- access := components.Service{
- Definition: s,
- SubPath: s,
- Details: map[string][]string{"forms": {"mqttPayload"}},
- RegPeriod: 30,
- Description: "Read the current topic message (GET) or publish to it (PUT)",
- }
+ return ua, func() {
+ log.Println("Disconnecting from MQTT broker")
+ ua.mClient.Disconnect(250)
+ }
+}
- // Check if the unit asset already exists in the assetMap
- ua, exists := assetMap[aName]
-
- if !exists {
- // Instantiate a new concrete type `MyUnitAsset` implementing `UnitAsset`
- ua := &UnitAsset{
- Name: aName,
- Owner: sys,
- Details: make(map[string][]string), // Initialize the map here
- ServicesMap: components.Services{
- access.SubPath: &access,
- },
- // Initialize fields
- client: mClient,
- topic: topicItem,
- serviceDef: s,
- metatopic: a,
- }
-
- // Add details on the unit asset based on the topic
- metaDetails := strings.Split(a, "/")
- for i := 0; i < len(uac.Pattern) && i < len(metaDetails); i++ {
- ua.Details[uac.Pattern[i]] = append(ua.Details[uac.Pattern[i]], metaDetails[i])
- }
-
- // Add the new asset to the assetList and assetMap
- assetList = append(assetList, ua)
- assetMap[aName] = ua
- } else {
- // If the asset exists, just add the new service to the ServicesMap
- ua.(*UnitAsset).ServicesMap[access.SubPath] = &access
- }
- // Subscribe to the topic
- if token := mClient.Subscribe(topicItem, 0, messageHandler); token.Wait() && token.Error() != nil {
- log.Fatalf("Error subscribing to topic: %v", token.Error())
+// UnmarshalTraits unmarshals a slice of json.RawMessage into a slice of Traits.
+func UnmarshalTraits(rawTraits []json.RawMessage) ([]Traits, error) {
+ var traitsList []Traits
+ for _, raw := range rawTraits {
+ var t Traits
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal trait: %w", err)
}
- fmt.Printf("Subscribed to topic: %s\n", topicItem)
- }
- return assetList, func() {
- log.Println("Disconnecting from MQTT broker")
- mClient.Disconnect(250)
+ traitsList = append(traitsList, t)
}
+ return traitsList, nil
}
//-------------------------------------Unit asset's resource functions
-// subscribeToTopic subscribes to the given MQTT topic
-func (ua *UnitAsset) subscribeToTopic() {
- // Define the message handler callback
- messageHandler := func(client mqtt.Client, msg mqtt.Message) {
- fmt.Printf("Received message: %s from topic: %s\n", msg.Payload(), msg.Topic())
- // ua.message = msg.Payload()
+// publishToTopic publishes a payload to the MQTT topic of the unit asset.
+func (ua *UnitAsset) publishToTopic(payload map[string]interface{}, contentType string) error {
+ if ua.mClient == nil {
+ return fmt.Errorf("MQTT client not initialized")
}
- // Subscribe to the topic
- theTopic := ua.metatopic + "/" + ua.serviceDef
- if token := ua.client.Subscribe(theTopic, 0, messageHandler); token.Wait() && token.Error() != nil {
- log.Fatalf("Error subscribing to topic: %v", token.Error())
+ // Serialize the message based on content type
+ // var data []byte
+ // var err error
+ // switch contentType {
+ // case "application/json":
+ // data, err = json.Marshal(payload)
+ // default:
+ // // Fallback to JSON encoding for now
+ // data, err = json.Marshal(payload)
+ // }
+ // if err != nil {
+ // return fmt.Errorf("failed to encode payload: %w", err)
+ // }
+ log.Println(contentType)
+
+ token := ua.mClient.Publish(ua.Topic, 0, false, payload)
+ token.Wait()
+ if token.Error() != nil {
+ return fmt.Errorf("publish error: %w", token.Error())
}
- fmt.Printf("Subscribed to topic: %s\n", theTopic)
+ return nil
+}
+
+func (ua *UnitAsset) publishRaw(data []byte) error {
+ // Just publish and return immediately
+ token := ua.mClient.Publish(ua.Topic, 0, false, data)
+
+ go func() {
+ token.Wait()
+ if err := token.Error(); err != nil {
+ log.Printf("Async publish error: %v", err)
+ }
+ }()
+
+ return nil
}
diff --git a/thermostat/README.md b/thermostat/README.md
index 156a1a9..8062ab6 100644
--- a/thermostat/README.md
+++ b/thermostat/README.md
@@ -10,11 +10,9 @@ It offers three services, *setpoint*, *thermalerror* and *jitter*. The setpoint
The control loop is executed every 10 seconds, and can be configured.
## Compiling
-To compile the code, one needs to get the AiGo module
-```go get github.com/sdoque/mbaigo```
-and initialize the *go.mod* file with ``` go mod init github.com/sdoque/systems/thermostat``` before running *go mod tidy*.
+To compile the code, one needs to initialize the *go.mod* file with ``` go mod init github.com/sdoque/systems/thermostat``` before running *go mod tidy*.
-To run the code, one just needs to type in ```go run thermostat.go thing.go``` within a terminal or at a command prompt.
+To run the code, one just needs to type in ```go run .``` within a terminal or at a command prompt.
It is **important** to start the program from within its own directory (and each system should have their own directory) because it looks for its configuration file there. If it does not find it there, it will generate one and shutdown to allow the configuration file to be updated.
@@ -26,13 +24,13 @@ To build the software for one's own machine,
## Cross compiling/building
The following commands enable one to build for different platforms:
-- Intel Mac: ```GOOS=darwin GOARCH=amd64 go build -o thermostat_imac thermostat.go thing.go```
-- ARM Mac: ```GOOS=darwin GOARCH=arm64 go build -o thermostat_amac thermostat.go thing.go```
-- Windows 64: ```GOOS=windows GOARCH=amd64 go build -o thermostat.exe thermostat.go thing.go```
-- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o thermostat_rpi64 thermostat.go thing.go```
-- Linux: ```GOOS=linux GOARCH=amd64 go build -o thermostat_linux thermostat.go thing.go```
+- Intel Mac: ```GOOS=darwin GOARCH=amd64 go build -o thermostat_imac ```
+- ARM Mac: ```GOOS=darwin GOARCH=arm64 go build -o thermostat_amac ```
+- Windows 64: ```GOOS=windows GOARCH=amd64 go build -o thermostat.exe```
+- Raspberry Pi 64: ```GOOS=linux GOARCH=arm64 go build -o thermostat_rpi64```
+- Linux: ```GOOS=linux GOARCH=amd64 go build -o thermostat_linux ```
One can find a complete list of platform by typing *go tool dist list* at the command prompt
If one wants to secure copy it to a Raspberry pi,
-`scp thermostat_rpi64 username@ipAddress:mbaigo/thermostat/` where user is the *username* @ the *IP address* of the Raspberry Pi with a relative (to the user's home directory) target *mbaigo/thermostat/* directory.
\ No newline at end of file
+`scp thermostat_rpi64 username@ipAddress:rpiExec/thermostat/` where user is the *username* @ the *IP address* of the Raspberry Pi with a relative (to the user's home directory) target *rpiExec/thermostat/* directory.
\ No newline at end of file
diff --git a/thermostat/thermostat.go b/thermostat/thermostat.go
index cb7ec83..7f08edf 100644
--- a/thermostat/thermostat.go
+++ b/thermostat/thermostat.go
@@ -18,6 +18,7 @@ package main
import (
"context"
+ "crypto/x509/pkix"
"encoding/json"
"fmt"
"log"
@@ -43,6 +44,14 @@ func main() {
Details: map[string][]string{"Developer": {"Synecdoque"}},
ProtoPort: map[string]int{"https": 0, "http": 20152, "coap": 0},
InfoLink: "https://github.com/sdoque/systems/tree/main/thermostat",
+ DName: pkix.Name{
+ CommonName: sys.Name,
+ Organization: []string{"Synecdoque"},
+ OrganizationalUnit: []string{"Systems"},
+ Locality: []string{"Luleå"},
+ Province: []string{"Norrbotten"},
+ Country: []string{"SE"},
+ },
}
// instantiate a template unit asset
@@ -51,17 +60,19 @@ func main() {
sys.UAssets[assetName] = &assetTemplate
// Configure the system
- rawResources, servsTemp, err := usecases.Configure(&sys)
+ rawResources, err := usecases.Configure(&sys)
if err != nil {
log.Fatalf("Configuration error: %v\n", err)
}
sys.UAssets = make(map[string]*components.UnitAsset) // clear the unit asset map (from the template)
+ var cleanups []func()
for _, raw := range rawResources {
- var uac UnitAsset
+ var uac usecases.ConfigurableAsset
if err := json.Unmarshal(raw, &uac); err != nil {
- log.Fatalf("Resource configuration error: %+v\n", err)
+ log.Fatalf("resource configuration error: %+v\n", err)
}
- ua, cleanup := newResource(uac, &sys, servsTemp)
+ ua, cleanup := newResource(uac, &sys)
+ cleanups = append(cleanups, cleanup)
defer cleanup()
sys.UAssets[ua.GetName()] = &ua
}
diff --git a/thermostat/thing.go b/thermostat/thing.go
index 54c2ac5..ca5ba3f 100644
--- a/thermostat/thing.go
+++ b/thermostat/thing.go
@@ -18,6 +18,8 @@ package main
import (
"context"
+ "encoding/json"
+ "fmt"
"log"
"time"
@@ -29,6 +31,18 @@ import (
//-------------------------------------Define the unit asset
// UnitAsset type models the unit asset (interface) of the system
+// Traits are Asset-specific configurable parameters
+type Traits struct {
+ SetPt float64 `json:"setPoint"`
+ Period time.Duration `json:"samplingPeriod"`
+ Kp float64 `json:"kp"`
+ Lambda float64 `json:"lambda"`
+ Ki float64 `json:"ki"`
+ jitter time.Duration `json:"-"`
+ deviation float64 `json:"-"`
+ previousT float64 `json:"-"`
+}
+
type UnitAsset struct {
Name string `json:"name"`
Owner *components.System `json:"-"`
@@ -36,14 +50,7 @@ type UnitAsset struct {
ServicesMap components.Services `json:"-"`
CervicesMap components.Cervices `json:"-"`
//
- jitter time.Duration
- Setpt float64 `json:"setpoint"`
- Period time.Duration `json:"samplingPeriod"`
- Kp float64 `json:"kp"`
- Lambda float64 `json:"lamda"`
- Ki float64 `json:"ki"`
- deviation float64
- previousT float64
+ Traits
}
// GetName returns the name of the Resource.
@@ -66,6 +73,11 @@ func (ua *UnitAsset) GetDetails() map[string][]string {
return ua.Details
}
+// GetTraits returns the traits of the Resource.
+func (ua *UnitAsset) GetTraits() any {
+ return ua.Traits
+}
+
// ensure UnitAsset implements components.UnitAsset (this check is done at during the compilation)
var _ components.UnitAsset = (*UnitAsset)(nil)
@@ -96,15 +108,19 @@ func initTemplate() components.UnitAsset {
Description: "provides the current jitter or control algorithm execution calculated every period (GET)",
}
+ assetTraits := Traits{
+ SetPt: 20,
+ Period: 10,
+ Kp: 5,
+ Lambda: 0.5,
+ Ki: 0,
+ }
+
// var uat components.UnitAsset // this is an interface, which we then initialize
uat := &UnitAsset{
Name: "controller_1",
Details: map[string][]string{"Location": {"Kitchen"}},
- Setpt: 20,
- Period: 10,
- Kp: 5,
- Lambda: 0.5,
- Ki: 0,
+ Traits: assetTraits,
ServicesMap: components.Services{
setPointService.SubPath: &setPointService,
thermalErrorService.SubPath: &thermalErrorService,
@@ -117,7 +133,7 @@ func initTemplate() components.UnitAsset {
//-------------------------------------Instantiate the unit assets based on configuration
// newResource creates the Resource resource with its pointers and channels based on the configuration using the tConig structs
-func newResource(uac UnitAsset, sys *components.System, servs []components.Service) (components.UnitAsset, func()) {
+func newResource(configuredAsset usecases.ConfigurableAsset, sys *components.System) (components.UnitAsset, func()) {
// determine the protocols that the system supports
sProtocols := components.SProtocols(sys.Husk.ProtoPort)
// instantiate the consumed services
@@ -134,20 +150,23 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi
}
// instantiate the unit asset
ua := &UnitAsset{
- Name: uac.Name,
+ Name: configuredAsset.Name,
Owner: sys,
- Details: uac.Details,
- ServicesMap: components.CloneServices(servs),
- Setpt: uac.Setpt,
- Period: uac.Period,
- Kp: uac.Kp,
- Lambda: uac.Lambda,
- Ki: uac.Ki,
+ Details: configuredAsset.Details,
+ ServicesMap: usecases.MakeServiceMap(configuredAsset.Services),
CervicesMap: components.Cervices{
t.Definition: t,
r.Definition: r,
},
}
+
+ traits, err := UnmarshalTraits(configuredAsset.Traits)
+ if err != nil {
+ log.Println("Warning: could not unmarshal traits:", err)
+ } else if len(traits) > 0 {
+ ua.Traits = traits[0] // or handle multiple traits if needed
+ }
+
// thermalUnit := ua.ServicesMap["setpoint"].Details["Unit"][0] // the measurement done below are still in Celsius, so allowing it to be configurable does not really make sense at this point
ua.CervicesMap["temperature"].Details = components.MergeDetails(ua.Details, map[string][]string{"Unit": {"Celsius"}, "Forms": {"SignalA_v1a"}})
ua.CervicesMap["rotation"].Details = components.MergeDetails(ua.Details, map[string][]string{"Unit": {"Percent"}, "Forms": {"SignalA_v1a"}})
@@ -160,12 +179,25 @@ func newResource(uac UnitAsset, sys *components.System, servs []components.Servi
}
}
+// UnmarshalTraits unmarshals a slice of json.RawMessage into a slice of Traits.
+func UnmarshalTraits(rawTraits []json.RawMessage) ([]Traits, error) {
+ var traitsList []Traits
+ for _, raw := range rawTraits {
+ var t Traits
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal trait: %w", err)
+ }
+ traitsList = append(traitsList, t)
+ }
+ return traitsList, nil
+}
+
//-------------------------------------Thing's resource methods
// getSetPoint fills out a signal form with the current thermal setpoint
func (ua *UnitAsset) getSetPoint() (f forms.SignalA_v1a) {
f.NewForm()
- f.Value = ua.Setpt
+ f.Value = ua.SetPt
f.Unit = "Celsius"
f.Timestamp = time.Now()
return f
@@ -173,7 +205,7 @@ func (ua *UnitAsset) getSetPoint() (f forms.SignalA_v1a) {
// setSetPoint updates the thermal setpoint
func (ua *UnitAsset) setSetPoint(f forms.SignalA_v1a) {
- ua.Setpt = f.Value
+ ua.SetPt = f.Value
log.Printf("new set point: %.1f", f.Value)
}
@@ -230,7 +262,7 @@ func (ua *UnitAsset) processFeedbackLoop() {
}
// perform the control algorithm
- ua.deviation = ua.Setpt - tup.Value
+ ua.deviation = ua.SetPt - tup.Value
output := ua.calculateOutput(ua.deviation)
// prepare the form to send