From ef92ddeb9f99e00a015beea651802b1ccd40b3d4 Mon Sep 17 00:00:00 2001 From: Douglas Hubler Date: Fri, 26 Jan 2024 08:53:39 -0500 Subject: [PATCH] move car example here where it can be maintained better --- testdata/car/car.go | 234 ++++++++++++++++++++++++++++++++++++ testdata/car/car.yang | 188 +++++++++++++++++++++++++++++ testdata/car/car_test.go | 35 ++++++ testdata/car/manage.go | 86 +++++++++++++ testdata/car/manage_test.go | 88 ++++++++++++++ testdata/car/ypath.go | 13 ++ 6 files changed, 644 insertions(+) create mode 100644 testdata/car/car.go create mode 100644 testdata/car/car.yang create mode 100644 testdata/car/car_test.go create mode 100644 testdata/car/manage.go create mode 100644 testdata/car/manage_test.go create mode 100644 testdata/car/ypath.go diff --git a/testdata/car/car.go b/testdata/car/car.go new file mode 100644 index 0000000..92d0ed4 --- /dev/null +++ b/testdata/car/car.go @@ -0,0 +1,234 @@ +package car + +import ( + "container/list" + "errors" + "fmt" + "math" + "math/rand" + "time" +) + +// //////////////////////// +// C A R - example application +// +// This has nothing to do with FreeCONF, just an example application written in Go. +// that models a running car that can get flat tires when tires are worn. +type Car struct { + Tire []*Tire + Miles float64 + Speed int + OilLevel float64 + Running bool + LastRotation int64 // When the tires were last rotated + + // Listeners are common on manageable code. Having said that, listeners + // remain relevant to your application. The manage.go file is responsible + // for bridging the conversion from application to management api. + listeners *list.List + + pollInterval time.Duration // adjust in unit testing to speed up tests +} + +// CarListener for receiving car update events +type CarListener func(UpdateEvent) + +// car event types +type UpdateEvent int + +const ( + CarStarted UpdateEvent = iota + 1 + CarStopped + FlatTire + BadOilLevel +) + +func (e UpdateEvent) String() string { + strs := []string{ + "unknown", + "carStarted", + "carStopped", + "flatTire", + "badOilLevel", + } + if int(e) < len(strs) { + return strs[e] + } + return "invalid" +} + +func New() *Car { + c := &Car{ + listeners: list.New(), + OilLevel: 10, + pollInterval: time.Second, + } + c.NewTires() + return c +} + +func (c *Car) endureMileage(miles float64) { + c.Miles += miles + c.OilLevel -= (miles * 0.0001) + if !c.checkOil() { + c.updateListeners(BadOilLevel) + c.Running = false + return + } + for _, t := range c.Tire { + // Wear down [0.0 - 0.5] of each tire proportionally to the tire position + t.Wear -= miles * float64(t.Pos) * rand.Float64() * 5 + t.checkIfFlat() + t.checkForWear() + if t.Flat { + c.Running = false + c.updateListeners(FlatTire) + } + } +} + +const oilOptimalLevel = 10.0 +const oilDamageMargin = 2.0 + +func (c *Car) checkOil() bool { + return math.Abs(oilOptimalLevel-c.OilLevel) <= oilDamageMargin +} + +// Stop will take up to poll_interval seconds to come to a stop +func (c *Car) Stop() { + c.Running = false +} + +func (c *Car) Start() { + if c.Running { + return + } + ticker := time.NewTicker(c.pollInterval) + c.Running = true + go func() { + c.updateListeners(CarStarted) + defer func() { + c.Running = false + c.updateListeners(CarStopped) + }() + for range ticker.C { + miles := float64(c.Speed) * (float64(time.Second) / float64(time.Hour)) + c.endureMileage(miles) + if !c.Running { + return + } + } + }() +} + +// OnUpdate to listen for car events like start, stop and flat tire +func (c *Car) OnUpdate(l CarListener) Subscription { + return NewSubscription(c.listeners, c.listeners.PushBack(l)) +} + +func (c *Car) NewTires() { + c.Tire = make([]*Tire, 4) + c.LastRotation = int64(c.Miles) + for pos := 0; pos < len(c.Tire); pos++ { + c.Tire[pos] = &Tire{ + Pos: pos, + Wear: 100, + Size: "H15", + } + } +} + +// ChangeOil is just an example for a function that accepts input and returns output that is +// mapped to an "rpc" in the YANG file. +func (c *Car) AddOil(drainFirst bool, amount float64) (float64, error) { + if drainFirst { + if c.Running { + return 0, errors.New("cannot change oil while car is running") + } + c.OilLevel = 0 + } + c.OilLevel = c.OilLevel + amount + if !c.checkOil() { + return 0, fmt.Errorf("invalid oil change level %.2f liters", c.OilLevel) + } + return c.OilLevel, nil +} + +func (c *Car) ReplaceTires() { + for _, t := range c.Tire { + t.Replace() + } + c.LastRotation = int64(c.Miles) +} + +func (c *Car) RotateTires() { + x := c.Tire[0] + c.Tire[0] = c.Tire[1] + c.Tire[1] = c.Tire[2] + c.Tire[2] = c.Tire[3] + c.Tire[3] = x + for i, t := range c.Tire { + t.Pos = i + } + c.LastRotation = int64(c.Miles) +} + +func (c *Car) updateListeners(e UpdateEvent) { + for i := c.listeners.Front(); i != nil; i = i.Next() { + i.Value.(CarListener)(e) + } +} + +// T I R E +type Tire struct { + Pos int + Size string + Flat bool + Wear float64 + Worn bool +} + +func (t *Tire) Replace() { + t.Wear = 100 + t.Flat = false + t.Worn = false +} + +func (t *Tire) checkIfFlat() { + if !t.Flat { + // emulate that the more wear a tire has, the more likely it will + // get a flat, but there is always a chance. + t.Flat = (t.Wear - (rand.Float64() * 10)) < 0 + } +} + +func (t *Tire) checkForWear() bool { + return t.Wear < 20 +} + +/////////////////////// +// U T I L + +// Subscription is handle into a list.List that when closed +// will automatically remove item from list. Useful for maintaining +// a set of listeners that can easily remove themselves. +type Subscription interface { + Close() error +} + +// NewSubscription is used by subscription managers to give a token +// to caller the can close to unsubscribe to events +func NewSubscription(l *list.List, e *list.Element) Subscription { + return &listSubscription{l, e} +} + +type listSubscription struct { + l *list.List + e *list.Element +} + +// Close will unsubscribe to events. +func (sub *listSubscription) Close() error { + sub.l.Remove(sub.e) + return nil +} diff --git a/testdata/car/car.yang b/testdata/car/car.yang new file mode 100644 index 0000000..16ba75e --- /dev/null +++ b/testdata/car/car.yang @@ -0,0 +1,188 @@ +// Every yang file has a single module (or sub-module) definition. The name of the module +// must match the name of the file. So module definition for "car" would be in "car.yang". +// Only exception to this rule is advanced naming schemes that introduce version into +// file name. +module car { + + description "Car goes beep beep"; + + revision 2023-03-27; // date YYYY-MM-DD is typical but you can use any scheme + + // globally unique id when used with module name should this YANG file mingles with other systems + namespace "freeconf.org/car"; + + prefix "car"; // used when importing definitions from other files which we don't use here + + // While any order is fine, it does control the order of data returned in the management + // interface or the order configuration is applied. You can mix order of metrics and + // config, children, rpcs, notifications as you see fit + + // begin car root config... + + leaf speed { + description "how many miles the car travels in one poll interval"; + type int32; + units milesPerHour; + default 1000; + } + + // begin car root metrics... + + leaf running { + description "state of the car moving or not"; + type boolean; + config false; + } + + leaf miles { + description "odometer - how many miles has car moved"; + config false; + type decimal64 { + fraction-digits 2; + } + } + + leaf oilLevel { + description "how much oil is in the car"; + type int32; + config false; + units liters; + } + + leaf lastRotation { + description "the odometer reading of the last tire rotation"; + type int64; + config false; + } + + // begin children objects of car... + + list tire { + description "rubber circular part that makes contact with road"; + + // lists are most helpful when you identify a field or fields that uniquely identifies + // the items in the list. This is not strictly neccessary. + key pos; + + leaf pos { + description "numerical positions of 0 thru 3"; + type int32; + } + + // begin tire config... + + leaf size { + description "informational information of the size of the tire"; + type string; + default "H15"; + } + + // begin tire metrics + + leaf worn { + description "a somewhat subjective but deterministic value of the amount of + wear on a tire indicating tire should be replaced soon"; + config false; + type boolean; + } + + leaf wear { + description "number representing the amount of wear and tear on the tire. + The more wear on a tire the more likely it is to go flat."; + config false; + type decimal64 { + fraction-digits 2; + } + } + + leaf flat { + description "tire has a flat and car would be kept from running. Use + replace tire or tires to get car running again"; + config false; + type boolean; + } + + // begin tire RPCs... + + action replace { + description "replace just this tire"; + + // simple rpc with no input or output. + + // Side note: you could have designed this as an rpc at the root level that + // accepts tire position as a single argument but putting it here makes it + // more natural and simple to call. + } + } + + // In YANG 'rpc' and 'action' are identical but for historical reasons you must only + // use 'rpc' only when on the root and 'action' when inside a container or list. + + // begin car RPCs... + + rpc reset { + description "reset the odometer"; // somewhat unrealistic of a real car odometer + } + + rpc rotateTires { + description "rotate tires for optimal wear"; + } + + rpc replaceTires { + description "replace all tires with fresh tires and no wear"; + } + + rpc start { + description "start the car if it is not already started"; + } + + rpc stop { + description "stop the car if it is not already stopped"; + } + + rpc addOil { + description "manage the oil in the car"; + + input { + leaf drainFirst { + description "drain oil before adding new oil"; + type boolean; + } + + leaf amount { + description "how much oil you add"; + type decimal64; + units liters; + } + } + output { + leaf oilLevel { + description "how much oil is in the car after this operation"; + type decimal64; + units liters; + } + } + } + + // begin of car events... + + notification update { + description "important state information about your car"; + + leaf event { + type enumeration { + enum carStarted { + + // optional. by default the values start at 0 and increment 1 past the + // previous value. Numbered values may be even ever be used in your programs + // and therefore irrelevant. Here I define a value just to demonstrate I could. + value 1; + + } + enum carStopped; + enum flatTire; + enum badOilLevel; + } + } + } +} \ No newline at end of file diff --git a/testdata/car/car_test.go b/testdata/car/car_test.go new file mode 100644 index 0000000..21ed60d --- /dev/null +++ b/testdata/car/car_test.go @@ -0,0 +1,35 @@ +package car + +import ( + "fmt" + "testing" + "time" + + "github.com/freeconf/yang/fc" +) + +// Quick test of car's features using direct access to fields and methods +// again, nothing to do with FreeCONF. +func TestCar(t *testing.T) { + c := New() + c.pollInterval = time.Millisecond + c.Speed = 1000 + + events := make(chan UpdateEvent) + unsub := c.OnUpdate(func(e UpdateEvent) { + fmt.Printf("got event %s\n", e) + events <- e + }) + t.Log("waiting for car events...") + c.Start() + + fc.AssertEqual(t, CarStarted, <-events) + fc.AssertEqual(t, FlatTire, <-events) + fc.AssertEqual(t, CarStopped, <-events) + c.ReplaceTires() + c.Start() + + fc.AssertEqual(t, CarStarted, <-events) + unsub.Close() + c.Stop() +} diff --git a/testdata/car/manage.go b/testdata/car/manage.go new file mode 100644 index 0000000..b0bee43 --- /dev/null +++ b/testdata/car/manage.go @@ -0,0 +1,86 @@ +package car + +import ( + "github.com/freeconf/yang/node" + "github.com/freeconf/yang/nodeutil" +) + +// /////////////////////// +// C A R M A N A G E M E N T +// +// Manage your car application using FreeCONF library according to the car.yang +// model file. +// +// Manage is root handler from car.yang. i.e. module car { ... } +func Manage(car *Car) node.Node { + + // We're letting reflect do a lot of the work when the yang file matches + // the field names and methods in the objects. But we extend reflection + // to add as much custom behavior as we want + return &nodeutil.Node{ + + // Initial object. Note: as the tree is traversed, new Node instances + // will have different values in their Object reference + Object: car, + + Options: nodeutil.NodeOptions{ + ActionInputExploded: true, + ActionOutputExploded: true, + }, + + // implement RPCs + OnAction: func(n *nodeutil.Node, r node.ActionRequest) (node.Node, error) { + switch r.Meta.Ident() { + case "reset": + // here we implement custom handler for action just as an example + // If there was a Reset() method on car then this switch/case would + // not be nec. + car.Miles = 0 + default: + // all the actions like start, stop and rotateTire are called + // thru reflecton here because their method names align with + // the YANG. + return n.DoAction(r) + } + return nil, nil + }, + + // implement yang notifications (which are really just event streams) + OnNotify: func(p *nodeutil.Node, r node.NotifyRequest) (node.NotifyCloser, error) { + switch r.Meta.Ident() { + case "update": + // can use an adhoc struct send event + sub := car.OnUpdate(func(e UpdateEvent) { + msg := struct { + Event int + }{ + Event: int(e), + } + // events are nodes too + r.Send(nodeutil.ReflectChild(&msg)) + }) + + // we return a close **function**, we are not actually closing here + return sub.Close, nil + } + + // there is no default implementation at this time, all notification streams + // require custom handlers. + return p.Notify(r) + }, + + // See nodeutil.Node for list of all possible handlers, but just some of the more + // useful ones include: + // + // OnField - custom leaf handling + // OnChild - custom container and list handling + // OnOptions - changing options for select parts of the yang tree + // OnRead - custom reflect value handling for reads + // OnWrite - custom reflect value handling for writes + // OnNewChild - custom data structure instantiation + // OnEndEdit - custom validation for making changes to data structures + // OnChoose - custom handling for choice statements + // OnContext - among other things, passing data down the tree + // ...more + } +} diff --git a/testdata/car/manage_test.go b/testdata/car/manage_test.go new file mode 100644 index 0000000..90c1e5e --- /dev/null +++ b/testdata/car/manage_test.go @@ -0,0 +1,88 @@ +package car + +import ( + "testing" + "time" + + "github.com/freeconf/yang/fc" + "github.com/freeconf/yang/node" + "github.com/freeconf/yang/nodeutil" + "github.com/freeconf/yang/parser" + "github.com/freeconf/yang/source" +) + +// Test the car management logic in manage.go +func TestManage(t *testing.T) { + + // setup + ypath := source.Path(".") + mod := parser.RequireModule(ypath, "car") + app := New() + app.pollInterval = time.Millisecond + + // no web server needed, just your app and management function. + brwsr := node.NewBrowser(mod, Manage(app)) + root := brwsr.Root() + + // read all config + currCfg, err := nodeutil.WriteJSON(sel(root.Find("?content=config"))) + fc.AssertEqual(t, nil, err) + expected := `{"speed":0,"oilLevel":10,"tire":[{"pos":0,"size":"H15"},{"pos":1,"size":"H15"},{"pos":2,"size":"H15"},{"pos":3,"size":"H15"}]}` + fc.AssertEqual(t, expected, currCfg) + + // access car and verify w/API + fc.AssertEqual(t, false, app.Running) + + // setup event listener, verify events later + events := make(chan string) + unsub, err := sel(root.Find("update")).Notifications(func(n node.Notification) { + event, _ := nodeutil.WriteJSON(n.Event) + events <- event + }) + fc.AssertEqual(t, nil, err) + fc.AssertEqual(t, 1, app.listeners.Len()) + + // write config starts car + err = root.UpdateFrom(nodeutil.ReadJSON(`{"speed":1000}`)) + fc.AssertEqual(t, nil, err) + fc.AssertEqual(t, 1000, app.Speed) + + // start car + fc.AssertEqual(t, nil, justErr(sel(root.Find("start")).Action(nil))) + + // should be first event + fc.AssertEqual(t, `{"event":"carStarted"}`, <-events) + fc.AssertEqual(t, true, app.Running) + + // unsubscribe + unsub() + fc.AssertEqual(t, 0, app.listeners.Len()) + + // hit all the RPCs + fc.AssertEqual(t, nil, justErr(sel(root.Find("rotateTires")).Action(nil))) + fc.AssertEqual(t, nil, justErr(sel(root.Find("replaceTires")).Action(nil))) + fc.AssertEqual(t, nil, justErr(sel(root.Find("reset")).Action(nil))) + fc.AssertEqual(t, nil, justErr(sel(root.Find("tire=0/replace")).Action(nil))) + fc.AssertEqual(t, nil, justErr(sel(root.Find("stop")).Action(nil))) + fc.AssertEqual(t, false, app.Running) + fc.AssertEqual(t, nil, justErr(sel(root.Find("stop")).Action(nil))) + out, err := sel(root.Find("addOil")).Action(nodeutil.ReadJSON(`{"drainFirst": true, "amount": 10.4}`)) + fc.AssertEqual(t, nil, err) + expectedOil, err := nodeutil.WriteJSON(out) + fc.AssertEqual(t, nil, err) + fc.AssertEqual(t, `{"oilLevel":10.4}`, expectedOil) + + fc.AssertEqual(t, nil, justErr(sel(root.Find("start")).Action(nil))) + fc.AssertEqual(t, true, app.Running) +} + +func sel(s *node.Selection, err error) *node.Selection { + if err != nil { + panic(err) + } + return s +} + +func justErr(_ *node.Selection, err error) error { + return err +} diff --git a/testdata/car/ypath.go b/testdata/car/ypath.go new file mode 100644 index 0000000..f3a9623 --- /dev/null +++ b/testdata/car/ypath.go @@ -0,0 +1,13 @@ +package car + +import ( + "embed" + + "github.com/freeconf/yang/source" +) + +//go:embed *.yang car.go manage.go +var internal embed.FS + +// Access to car.yang +var YPath = source.EmbedDir(internal, ".")