1
0
Fork 0

Adding upstream version 1.34.4.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-24 07:26:29 +02:00
parent e393c3af3f
commit 4978089aab
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
4963 changed files with 677545 additions and 0 deletions

View file

@ -0,0 +1,78 @@
# Fibaro Input Plugin
This plugin gathers data from devices connected to a [Fibaro][fibaro]
controller. Those values could be true (1) or false (0) for switches, percentage
for dimmers, temperature, etc. Both _Home Center 2_ and _Home Center 3_ devices
are supported.
⭐ Telegraf v1.7.0
🏷️ iot
💻 all
[fibaro]: https://www.fibaro.com
## Global configuration options <!-- @/docs/includes/plugin_config.md -->
In addition to the plugin-specific configuration settings, plugins support
additional global and plugin configuration settings. These settings are used to
modify metrics, tags, and field or create aliases and configure ordering, etc.
See the [CONFIGURATION.md][CONFIGURATION.md] for more details.
[CONFIGURATION.md]: ../../../docs/CONFIGURATION.md#plugins
## Configuration
```toml @sample.conf
# Read devices value(s) from a Fibaro controller
[[inputs.fibaro]]
## Required Fibaro controller address/hostname.
## Note: at the time of writing this plugin, Fibaro only implemented http - no https available
url = "http://<controller>:80"
## Required credentials to access the API (http://<controller/api/<component>)
username = "<username>"
password = "<password>"
## Amount of time allowed to complete the HTTP request
# timeout = "5s"
## Fibaro Device Type
## By default, this plugin will attempt to read using the HC2 API. For HC3
## devices, set this to "HC3"
# device_type = "HC2"
```
## Metrics
- fibaro
- tags:
- deviceId (device id)
- section (section name)
- room (room name)
- name (device name)
- type (device type)
- fields:
- batteryLevel (float, when available from device)
- energy (float, when available from device)
- power (float, when available from device)
- value (float)
- value2 (float, when available from device)
## Example Output
```text
fibaro,deviceId=9,host=vm1,name=Fenêtre\ haute,room=Cuisine,section=Cuisine,type=com.fibaro.FGRM222 energy=2.04,power=0.7,value=99,value2=99 1529996807000000000
fibaro,deviceId=10,host=vm1,name=Escaliers,room=Dégagement,section=Pièces\ communes,type=com.fibaro.binarySwitch value=0 1529996807000000000
fibaro,deviceId=13,host=vm1,name=Porte\ fenêtre,room=Salon,section=Pièces\ communes,type=com.fibaro.FGRM222 energy=4.33,power=0.7,value=99,value2=99 1529996807000000000
fibaro,deviceId=21,host=vm1,name=LED\ îlot\ central,room=Cuisine,section=Cuisine,type=com.fibaro.binarySwitch value=0 1529996807000000000
fibaro,deviceId=90,host=vm1,name=Détérioration,room=Entrée,section=Pièces\ communes,type=com.fibaro.heatDetector value=0 1529996807000000000
fibaro,deviceId=163,host=vm1,name=Température,room=Cave,section=Cave,type=com.fibaro.temperatureSensor value=21.62 1529996807000000000
fibaro,deviceId=191,host=vm1,name=Présence,room=Garde-manger,section=Cuisine,type=com.fibaro.FGMS001 value=1 1529996807000000000
fibaro,deviceId=193,host=vm1,name=Luminosité,room=Garde-manger,section=Cuisine,type=com.fibaro.lightSensor value=195 1529996807000000000
fibaro,deviceId=200,host=vm1,name=Etat,room=Garage,section=Extérieur,type=com.fibaro.doorSensor value=0 1529996807000000000
fibaro,deviceId=220,host=vm1,name=CO2\ (ppm),room=Salon,section=Pièces\ communes,type=com.fibaro.multilevelSensor value=536 1529996807000000000
fibaro,deviceId=221,host=vm1,name=Humidité\ (%),room=Salon,section=Pièces\ communes,type=com.fibaro.humiditySensor value=61 1529996807000000000
fibaro,deviceId=222,host=vm1,name=Pression\ (mb),room=Salon,section=Pièces\ communes,type=com.fibaro.multilevelSensor value=1013.7 1529996807000000000
fibaro,deviceId=223,host=vm1,name=Bruit\ (db),room=Salon,section=Pièces\ communes,type=com.fibaro.multilevelSensor value=44 1529996807000000000
fibaro,deviceId=248,host=vm1,name=Température,room=Garage,section=Extérieur,type=com.fibaro.temperatureSensor batteryLevel=85,value=10.8 1529996807000000000
```

View file

@ -0,0 +1,123 @@
//go:generate ../../../tools/readme_config_includer/generator
package fibaro
import (
_ "embed"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/config"
"github.com/influxdata/telegraf/plugins/inputs"
"github.com/influxdata/telegraf/plugins/inputs/fibaro/hc2"
"github.com/influxdata/telegraf/plugins/inputs/fibaro/hc3"
)
//go:embed sample.conf
var sampleConfig string
const defaultTimeout = 5 * time.Second
type Fibaro struct {
URL string `toml:"url"`
Username string `toml:"username"`
Password string `toml:"password"`
Timeout config.Duration `toml:"timeout"`
DeviceType string `toml:"device_type"`
client *http.Client
}
func (*Fibaro) SampleConfig() string {
return sampleConfig
}
func (f *Fibaro) Init() error {
switch f.DeviceType {
case "":
f.DeviceType = "HC2"
case "HC2", "HC3":
default:
return errors.New("invalid option for device type")
}
return nil
}
func (f *Fibaro) Gather(acc telegraf.Accumulator) error {
if f.client == nil {
f.client = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
Timeout: time.Duration(f.Timeout),
}
}
sections, err := f.getJSON("/api/sections")
if err != nil {
return err
}
rooms, err := f.getJSON("/api/rooms")
if err != nil {
return err
}
devices, err := f.getJSON("/api/devices")
if err != nil {
return err
}
switch f.DeviceType {
case "HC2":
return hc2.Parse(acc, sections, rooms, devices)
case "HC3":
return hc3.Parse(acc, sections, rooms, devices)
}
return nil
}
// getJSON connects, authenticates and reads JSON payload returned by Fibaro box
func (f *Fibaro) getJSON(path string) ([]byte, error) {
var requestURL = f.URL + path
req, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(f.Username, f.Password)
resp, err := f.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("response from url %q has status code %d (%s), expected %d (%s)",
requestURL,
resp.StatusCode,
http.StatusText(resp.StatusCode),
http.StatusOK,
http.StatusText(http.StatusOK))
return nil, err
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("unable to read response body: %w", err)
}
return bodyBytes, nil
}
func init() {
inputs.Add("fibaro", func() telegraf.Input {
return &Fibaro{
Timeout: config.Duration(defaultTimeout),
}
})
}

View file

@ -0,0 +1,306 @@
package fibaro
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/testutil"
)
// TestUnauthorized validates that 401 (wrong credentials) is managed properly
func TestUnauthorized(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer ts.Close()
a := Fibaro{
URL: ts.URL,
Username: "user",
Password: "pass",
client: &http.Client{},
}
require.NoError(t, a.Init())
var acc testutil.Accumulator
err := acc.GatherError(a.Gather)
require.Error(t, err)
}
// TestJSONSuccess validates that module works OK with valid JSON payloads
func TestJSONSuccess(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload := ""
switch r.URL.Path {
case "/api/sections":
content, err := os.ReadFile(path.Join("testdata", "sections.json"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
payload = string(content)
case "/api/rooms":
content, err := os.ReadFile(path.Join("testdata", "rooms.json"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
payload = string(content)
case "/api/devices":
content, err := os.ReadFile(path.Join("testdata", "device_hc2.json"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
payload = string(content)
}
w.WriteHeader(http.StatusOK)
if _, err := fmt.Fprintln(w, payload); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
}))
defer ts.Close()
a := Fibaro{
URL: ts.URL,
Username: "user",
Password: "pass",
client: &http.Client{},
}
require.NoError(t, a.Init())
var acc testutil.Accumulator
err := acc.GatherError(a.Gather)
require.NoError(t, err)
require.Equal(t, uint64(5), acc.NMetrics())
expected := []telegraf.Metric{
testutil.MustMetric(
"fibaro",
map[string]string{
"deviceId": "1",
"section": "Section 1",
"room": "Room 1",
"name": "Device 1",
"type": "com.fibaro.binarySwitch",
},
map[string]interface{}{
"value": float64(0),
},
time.Unix(0, 0),
),
testutil.MustMetric(
"fibaro",
map[string]string{
"deviceId": "2",
"section": "Section 2",
"room": "Room 2",
"name": "Device 2",
"type": "com.fibaro.binarySwitch",
},
map[string]interface{}{
"value": float64(1),
},
time.Unix(0, 0),
),
testutil.MustMetric(
"fibaro",
map[string]string{
"deviceId": "3",
"section": "Section 3",
"room": "Room 3",
"name": "Device 3",
"type": "com.fibaro.multilevelSwitch",
},
map[string]interface{}{
"value": float64(67),
},
time.Unix(0, 0),
),
testutil.MustMetric(
"fibaro",
map[string]string{
"deviceId": "4",
"section": "Section 3",
"room": "Room 4",
"name": "Device 4",
"type": "com.fibaro.temperatureSensor",
},
map[string]interface{}{
"batteryLevel": float64(100),
"value": float64(22.8),
},
time.Unix(0, 0),
),
testutil.MustMetric(
"fibaro",
map[string]string{
"deviceId": "5",
"section": "Section 3",
"room": "Room 4",
"name": "Device 5",
"type": "com.fibaro.FGRM222",
},
map[string]interface{}{
"energy": float64(4.33),
"power": float64(0.7),
"value": float64(50),
"value2": float64(75),
},
time.Unix(0, 0),
),
}
testutil.RequireMetricsEqual(t, expected, acc.GetTelegrafMetrics(), testutil.IgnoreTime())
}
func TestHC3JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload := ""
switch r.URL.Path {
case "/api/sections":
content, err := os.ReadFile(path.Join("testdata", "sections.json"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
payload = string(content)
case "/api/rooms":
content, err := os.ReadFile(path.Join("testdata", "rooms.json"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
payload = string(content)
case "/api/devices":
content, err := os.ReadFile(path.Join("testdata", "device_hc3.json"))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
payload = string(content)
}
w.WriteHeader(http.StatusOK)
if _, err := fmt.Fprintln(w, payload); err != nil {
w.WriteHeader(http.StatusInternalServerError)
t.Error(err)
return
}
}))
defer ts.Close()
a := Fibaro{
URL: ts.URL,
Username: "user",
Password: "pass",
DeviceType: "HC3",
client: &http.Client{},
}
require.NoError(t, a.Init())
var acc testutil.Accumulator
err := acc.GatherError(a.Gather)
require.NoError(t, err)
require.Equal(t, uint64(5), acc.NMetrics())
expected := []telegraf.Metric{
testutil.MustMetric(
"fibaro",
map[string]string{
"deviceId": "1",
"section": "Section 1",
"room": "Room 1",
"name": "Device 1",
"type": "com.fibaro.binarySwitch",
},
map[string]interface{}{
"value": float64(0),
},
time.Unix(0, 0),
),
testutil.MustMetric(
"fibaro",
map[string]string{
"deviceId": "2",
"section": "Section 2",
"room": "Room 2",
"name": "Device 2",
"type": "com.fibaro.binarySwitch",
},
map[string]interface{}{
"value": float64(1),
},
time.Unix(0, 0),
),
testutil.MustMetric(
"fibaro",
map[string]string{
"deviceId": "3",
"section": "Section 3",
"room": "Room 3",
"name": "Device 3",
"type": "com.fibaro.multilevelSwitch",
},
map[string]interface{}{
"value": float64(67),
},
time.Unix(0, 0),
),
testutil.MustMetric(
"fibaro",
map[string]string{
"deviceId": "4",
"section": "Section 3",
"room": "Room 4",
"name": "Device 4",
"type": "com.fibaro.temperatureSensor",
},
map[string]interface{}{
"batteryLevel": float64(100),
"value": float64(22.8),
},
time.Unix(0, 0),
),
testutil.MustMetric(
"fibaro",
map[string]string{
"deviceId": "5",
"section": "Section 3",
"room": "Room 4",
"name": "Device 5",
"type": "com.fibaro.FGRM222",
},
map[string]interface{}{
"energy": float64(4.33),
"power": float64(0.7),
"value": float64(34),
},
time.Unix(0, 0),
),
}
testutil.RequireMetricsEqual(t, expected, acc.GetTelegrafMetrics(), testutil.IgnoreTime())
}
func TestInvalidDeviceType(t *testing.T) {
a := Fibaro{
DeviceType: "foobar",
}
require.Error(t, a.Init())
}

View file

@ -0,0 +1,96 @@
package hc2
import (
"encoding/json"
"strconv"
"github.com/influxdata/telegraf"
)
// Parse parses data from sections, rooms and devices, and adds measurements containing parsed data.
func Parse(acc telegraf.Accumulator, sectionBytes, roomBytes, devicesBytes []byte) error {
var tmpSections []Sections
if err := json.Unmarshal(sectionBytes, &tmpSections); err != nil {
return err
}
sections := make(map[uint16]string, len(tmpSections))
for _, v := range tmpSections {
sections[v.ID] = v.Name
}
var tmpRooms []Rooms
if err := json.Unmarshal(roomBytes, &tmpRooms); err != nil {
return err
}
rooms := make(map[uint16]LinkRoomsSections, len(tmpRooms))
for _, v := range tmpRooms {
rooms[v.ID] = LinkRoomsSections{Name: v.Name, SectionID: v.SectionID}
}
var devices []Devices
if err := json.Unmarshal(devicesBytes, &devices); err != nil {
return err
}
for _, device := range devices {
// skip device in some cases
if device.RoomID == 0 ||
!device.Enabled ||
device.Properties.Dead == "true" ||
device.Type == "com.fibaro.zwaveDevice" {
continue
}
tags := map[string]string{
"deviceId": strconv.FormatUint(uint64(device.ID), 10),
"section": sections[rooms[device.RoomID].SectionID],
"room": rooms[device.RoomID].Name,
"name": device.Name,
"type": device.Type,
}
fields := make(map[string]interface{})
if device.Properties.BatteryLevel != nil {
if fValue, err := strconv.ParseFloat(*device.Properties.BatteryLevel, 64); err == nil {
fields["batteryLevel"] = fValue
}
}
if device.Properties.Energy != nil {
if fValue, err := strconv.ParseFloat(*device.Properties.Energy, 64); err == nil {
fields["energy"] = fValue
}
}
if device.Properties.Power != nil {
if fValue, err := strconv.ParseFloat(*device.Properties.Power, 64); err == nil {
fields["power"] = fValue
}
}
if device.Properties.Value != nil {
value := device.Properties.Value
switch value {
case "true":
value = "1"
case "false":
value = "0"
}
if fValue, err := strconv.ParseFloat(value.(string), 64); err == nil {
fields["value"] = fValue
}
}
if device.Properties.Value2 != nil {
if fValue, err := strconv.ParseFloat(*device.Properties.Value2, 64); err == nil {
fields["value2"] = fValue
}
}
acc.AddFields("fibaro", fields, tags)
}
return nil
}

View file

@ -0,0 +1,37 @@
package hc2
// LinkRoomsSections links rooms to sections
type LinkRoomsSections struct {
Name string
SectionID uint16
}
// Sections contains sections information
type Sections struct {
ID uint16 `json:"id"`
Name string `json:"name"`
}
// Rooms contains rooms information
type Rooms struct {
ID uint16 `json:"id"`
Name string `json:"name"`
SectionID uint16 `json:"sectionID"`
}
// Devices contains devices information
type Devices struct {
ID uint16 `json:"id"`
Name string `json:"name"`
RoomID uint16 `json:"roomID"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
Properties struct {
BatteryLevel *string `json:"batteryLevel"`
Dead string `json:"dead"`
Energy *string `json:"energy"`
Power *string `json:"power"`
Value interface{} `json:"value"`
Value2 *string `json:"value2"`
} `json:"properties"`
}

View file

@ -0,0 +1,81 @@
package hc3
import (
"encoding/json"
"fmt"
"strconv"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/internal"
)
// Parse parses data from sections, rooms and devices, and adds measurements containing parsed data.
func Parse(acc telegraf.Accumulator, sectionBytes, roomBytes, devicesBytes []byte) error {
var tmpSections []Sections
if err := json.Unmarshal(sectionBytes, &tmpSections); err != nil {
return err
}
sections := make(map[uint16]string, len(tmpSections))
for _, v := range tmpSections {
sections[v.ID] = v.Name
}
var tmpRooms []Rooms
if err := json.Unmarshal(roomBytes, &tmpRooms); err != nil {
return err
}
rooms := make(map[uint16]linkRoomsSections, len(tmpRooms))
for _, v := range tmpRooms {
rooms[v.ID] = linkRoomsSections{Name: v.Name, SectionID: v.SectionID}
}
var devices []Devices
if err := json.Unmarshal(devicesBytes, &devices); err != nil {
return err
}
for _, device := range devices {
// skip device in some cases
if device.RoomID == 0 ||
!device.Enabled ||
device.Properties.Dead ||
device.Type == "com.fibaro.zwaveDevice" {
continue
}
tags := map[string]string{
"deviceId": strconv.FormatUint(uint64(device.ID), 10),
"section": sections[rooms[device.RoomID].SectionID],
"room": rooms[device.RoomID].Name,
"name": device.Name,
"type": device.Type,
}
fields := make(map[string]interface{})
if device.Properties.BatteryLevel != nil {
fields["batteryLevel"] = *device.Properties.BatteryLevel
}
if device.Properties.Energy != nil {
fields["energy"] = *device.Properties.Energy
}
if device.Properties.Power != nil {
fields["power"] = *device.Properties.Power
}
// Value can be a JSON bool, string, or numeric value
if device.Properties.Value != nil {
v, err := internal.ToFloat64(device.Properties.Value)
if err != nil {
acc.AddError(fmt.Errorf("unable to convert value: %w", err))
} else {
fields["value"] = v
}
}
acc.AddFields("fibaro", fields, tags)
}
return nil
}

View file

@ -0,0 +1,37 @@
package hc3
// LinkRoomsSections links rooms to sections
type linkRoomsSections struct {
Name string
SectionID uint16
}
// Sections contains sections information
type Sections struct {
ID uint16 `json:"id"`
Name string `json:"name"`
}
// Rooms contains rooms information
type Rooms struct {
ID uint16 `json:"id"`
Name string `json:"name"`
SectionID uint16 `json:"sectionID"`
}
// Devices contains devices information
type Devices struct {
ID uint16 `json:"id"`
Name string `json:"name"`
RoomID uint16 `json:"roomID"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
Properties struct {
BatteryLevel *float64 `json:"batteryLevel"`
Dead bool `json:"dead"`
Energy *float64 `json:"energy"`
Power *float64 `json:"power"`
Value interface{} `json:"value"`
Value2 *string `json:"value2"`
} `json:"properties"`
}

View file

@ -0,0 +1,17 @@
# Read devices value(s) from a Fibaro controller
[[inputs.fibaro]]
## Required Fibaro controller address/hostname.
## Note: at the time of writing this plugin, Fibaro only implemented http - no https available
url = "http://<controller>:80"
## Required credentials to access the API (http://<controller/api/<component>)
username = "<username>"
password = "<password>"
## Amount of time allowed to complete the HTTP request
# timeout = "5s"
## Fibaro Device Type
## By default, this plugin will attempt to read using the HC2 API. For HC3
## devices, set this to "HC3"
# device_type = "HC2"

View file

@ -0,0 +1,66 @@
[
{
"id": 1,
"name": "Device 1",
"roomID": 1,
"type": "com.fibaro.binarySwitch",
"enabled": true,
"properties": {
"dead": "false",
"value": "false"
},
"sortOrder": 1
},
{
"id": 2,
"name": "Device 2",
"roomID": 2,
"type": "com.fibaro.binarySwitch",
"enabled": true,
"properties": {
"dead": "false",
"value": "true"
},
"sortOrder": 2
},
{
"id": 3,
"name": "Device 3",
"roomID": 3,
"type": "com.fibaro.multilevelSwitch",
"enabled": true,
"properties": {
"dead": "false",
"value": "67"
},
"sortOrder": 3
},
{
"id": 4,
"name": "Device 4",
"roomID": 4,
"type": "com.fibaro.temperatureSensor",
"enabled": true,
"properties": {
"batteryLevel": "100",
"dead": "false",
"value": "22.80"
},
"sortOrder": 4
},
{
"id": 5,
"name": "Device 5",
"roomID": 4,
"type": "com.fibaro.FGRM222",
"enabled": true,
"properties": {
"energy": "4.33",
"power": "0.7",
"dead": "false",
"value": "50",
"value2": "75"
},
"sortOrder": 5
}
]

View file

@ -0,0 +1,65 @@
[
{
"id": 1,
"name": "Device 1",
"roomID": 1,
"type": "com.fibaro.binarySwitch",
"enabled": true,
"properties": {
"dead": false,
"value": false
},
"sortOrder": 1
},
{
"id": 2,
"name": "Device 2",
"roomID": 2,
"type": "com.fibaro.binarySwitch",
"enabled": true,
"properties": {
"dead": false,
"value": true
},
"sortOrder": 2
},
{
"id": 3,
"name": "Device 3",
"roomID": 3,
"type": "com.fibaro.multilevelSwitch",
"enabled": true,
"properties": {
"dead": false,
"value": "67"
},
"sortOrder": 3
},
{
"id": 4,
"name": "Device 4",
"roomID": 4,
"type": "com.fibaro.temperatureSensor",
"enabled": true,
"properties": {
"batteryLevel": 100,
"dead": false,
"value": 22.80
},
"sortOrder": 4
},
{
"id": 5,
"name": "Device 5",
"roomID": 4,
"type": "com.fibaro.FGRM222",
"enabled": true,
"properties": {
"energy": 4.33,
"power": 0.7,
"dead": false,
"value": 34
},
"sortOrder": 5
}
]

View file

@ -0,0 +1,30 @@
[
{
"id": 1,
"name": "Room 1",
"sectionID": 1,
"icon": "room_1",
"sortOrder": 1
},
{
"id": 2,
"name": "Room 2",
"sectionID": 2,
"icon": "room_2",
"sortOrder": 2
},
{
"id": 3,
"name": "Room 3",
"sectionID": 3,
"icon": "room_3",
"sortOrder": 3
},
{
"id": 4,
"name": "Room 4",
"sectionID": 3,
"icon": "room_4",
"sortOrder": 4
}
]

View file

@ -0,0 +1,17 @@
[
{
"id": 1,
"name": "Section 1",
"sortOrder": 1
},
{
"id": 2,
"name": "Section 2",
"sortOrder": 2
},
{
"id": 3,
"name": "Section 3",
"sortOrder": 3
}
]