github.com/simpleiot/simpleiot@v0.18.3/client/rule.go (about) 1 package client 2 3 import ( 4 "fmt" 5 "log" 6 "os" 7 "os/exec" 8 "strconv" 9 "strings" 10 "time" 11 12 "github.com/go-audio/wav" 13 "github.com/google/uuid" 14 "github.com/nats-io/nats.go" 15 "github.com/simpleiot/simpleiot/data" 16 ) 17 18 // Rule represent a rule node config 19 type Rule struct { 20 ID string `node:"id"` 21 Parent string `node:"parent"` 22 Description string `point:"description"` 23 Disabled bool `point:"disabled"` 24 Active bool `point:"active"` 25 Error string `point:"error"` 26 Conditions []Condition `child:"condition"` 27 Actions []Action `child:"action"` 28 ActionsInactive []Action `child:"actionInactive"` 29 } 30 31 func (r Rule) String() string { 32 ret := fmt.Sprintf("Rule: %v\n", r.Description) 33 ret += fmt.Sprintf(" active: %v\n", r.Active) 34 ret += fmt.Sprintf(" Disabled: %v\n", r.Disabled) 35 for _, c := range r.Conditions { 36 ret += fmt.Sprintf("%v", c) 37 } 38 for _, a := range r.Actions { 39 ret += fmt.Sprintf(" ACTION: %v", a) 40 } 41 42 for _, a := range r.ActionsInactive { 43 ret += fmt.Sprintf(" ACTION Inactive: %v", a) 44 } 45 46 return ret 47 } 48 49 // Condition defines parameters to look for in a point or a schedule. 50 type Condition struct { 51 // general parameters 52 ID string `node:"id"` 53 Parent string `node:"parent"` 54 Description string `point:"description"` 55 Disabled bool `point:"disabled"` 56 ConditionType string `point:"conditionType"` 57 MinActive float64 `point:"minActive"` 58 Active bool `point:"active"` 59 Error string `point:"error"` 60 61 // used with point value rules 62 NodeID string `point:"nodeID"` 63 PointType string `point:"pointType"` 64 PointKey string `point:"pointKey"` 65 PointIndex int `point:"pointIndex"` 66 ValueType string `point:"valueType"` 67 Operator string `point:"operator"` 68 Value float64 `point:"value"` 69 ValueText string `point:"valueText"` 70 71 // used with shedule rules 72 Start string `point:"start"` 73 End string `point:"end"` 74 Weekdays []bool `point:"weekday"` 75 Dates []string `point:"date"` 76 } 77 78 func (c Condition) String() string { 79 value := "" 80 switch c.ValueType { 81 case data.PointValueOnOff: 82 if c.Value == 0 { 83 value = "off" 84 } else { 85 value = "on" 86 } 87 case data.PointValueNumber: 88 value = strconv.FormatFloat(c.Value, 'f', 2, 64) 89 case data.PointValueText: 90 value = c.ValueText 91 } 92 93 var ret string 94 95 switch c.ConditionType { 96 case data.PointValuePointValue: 97 ret = fmt.Sprintf(" COND: %v Disabled: %v CTYPE:%v VTYPE:%v V:%v", 98 c.Description, c.ConditionType, c.Disabled, c.ValueType, value) 99 if c.NodeID != "" { 100 ret += fmt.Sprintf(" NODEID:%v", c.NodeID) 101 } 102 if c.MinActive > 0 { 103 ret += fmt.Sprintf(" MINACT:%v", c.MinActive) 104 } 105 ret += fmt.Sprintf(" A:%v", c.Active) 106 ret += "\n" 107 case data.PointValueSchedule: 108 ret = fmt.Sprintf(" COND: %v CTYPE:%v", 109 c.Description, c.ConditionType) 110 ret += fmt.Sprintf(" W:%v", c.Weekdays) 111 ret += fmt.Sprintf(" D:%v", c.Dates) 112 ret += "\n" 113 114 default: 115 ret = "Missing String case for condition" 116 } 117 return ret 118 } 119 120 // Action defines actions that can be taken if a rule is active. 121 type Action struct { 122 ID string `node:"id"` 123 Parent string `node:"parent"` 124 Description string `point:"description"` 125 Disabled bool `point:"disabled"` 126 Active bool `point:"active"` 127 Error string `point:"error"` 128 // Action: notify, setValue, playAudio 129 Action string `point:"action"` 130 NodeID string `point:"nodeID"` 131 PointType string `point:"pointType"` 132 PointKey string `point:"pointKey"` 133 // PointType: number, text, onOff 134 ValueType string `point:"valueType"` 135 Value float64 `point:"value"` 136 ValueText string `point:"valueText"` 137 // the following are used for audio playback 138 PointChannel int `point:"pointChannel"` 139 PointDevice string `point:"pointDevice"` 140 PointFilePath string `point:"pointFilePath"` 141 } 142 143 func (a Action) String() string { 144 value := "" 145 switch a.ValueType { 146 case data.PointValueOnOff: 147 if a.Value == 0 { 148 value = "off" 149 } else { 150 value = "on" 151 } 152 case data.PointValueNumber: 153 value = strconv.FormatFloat(a.Value, 'f', 2, 64) 154 case data.PointValueText: 155 value = a.ValueText 156 } 157 ret := fmt.Sprintf("%v Disabled:%v ACT:%v VTYPE:%v V:%v", 158 a.Description, a.Disabled, a.Action, a.ValueType, value) 159 if a.NodeID != "" { 160 ret += fmt.Sprintf(" NODEID:%v", a.NodeID) 161 } 162 if a.PointKey != "" && a.PointKey != "0" { 163 ret += fmt.Sprintf(" K:%v", a.PointKey) 164 } 165 ret += fmt.Sprintf(" A:%v", a.Active) 166 ret += "\n" 167 return ret 168 } 169 170 // ActionInactive defines actions that can be taken if a rule is inactive. 171 // this is defined for use with the client.SendNodeType API 172 type ActionInactive struct { 173 ID string `node:"id"` 174 Parent string `node:"parent"` 175 Description string `point:"description"` 176 Active bool `point:"active"` 177 // Action: notify, setValue, playAudio 178 Action string `point:"action"` 179 NodeID string `point:"nodeID"` 180 PointType string `point:"pointType"` 181 PointKey string `point:"pointKey"` 182 // PointType: number, text, onOff 183 ValueType string `point:"valueType"` 184 Value float64 `point:"value"` 185 ValueText string `point:"valueText"` 186 // the following are used for audio playback 187 PointChannel int `point:"pointChannel"` 188 PointDevice string `point:"pointDevice"` 189 PointFilePath string `point:"pointFilePath"` 190 } 191 192 // RuleClient is a SIOT client used to run rules 193 type RuleClient struct { 194 nc *nats.Conn 195 config Rule 196 stop chan struct{} 197 newPoints chan NewPoints 198 newEdgePoints chan NewPoints 199 newRulePoints chan NewPoints 200 upSub *nats.Subscription 201 } 202 203 // NewRuleClient constructor ... 204 func NewRuleClient(nc *nats.Conn, config Rule) Client { 205 return &RuleClient{ 206 nc: nc, 207 config: config, 208 stop: make(chan struct{}), 209 newPoints: make(chan NewPoints), 210 newEdgePoints: make(chan NewPoints), 211 newRulePoints: make(chan NewPoints), 212 } 213 } 214 215 // Run runs the main logic for this client and blocks until stopped 216 func (rc *RuleClient) Run() error { 217 // watch all points that flow through parent node 218 // TODO: we should optimize this so we only watch the nodes 219 // that are in the conditions 220 subject := fmt.Sprintf("up.%v.*", rc.config.Parent) 221 222 var err error 223 rc.upSub, err = rc.nc.Subscribe(subject, func(msg *nats.Msg) { 224 points, err := data.PbDecodePoints(msg.Data) 225 if err != nil { 226 log.Println("Error decoding points in rule upSub:", err) 227 return 228 } 229 230 // find node ID for points 231 chunks := strings.Split(msg.Subject, ".") 232 if len(chunks) != 3 { 233 log.Println("rule client up sub, malformed subject:", msg.Subject) 234 return 235 } 236 237 rc.newRulePoints <- NewPoints{chunks[2], "", points} 238 }) 239 240 if err != nil { 241 return fmt.Errorf("Rule error subscribing to upsub: %v", err) 242 } 243 244 // TODO schedule ticker is a brute force way to do this 245 // we could optimize at some point by creating a timer to expire 246 // on the next schedule change 247 scheduleTickTime := time.Second * 10 248 scheduleTicker := time.NewTicker(scheduleTickTime) 249 if !rc.hasSchedule() { 250 scheduleTicker.Stop() 251 } 252 253 run := func(id string, pts data.Points) { 254 var active, changed bool 255 var err error 256 257 if rc.config.Disabled { 258 active = false 259 } else { 260 if len(pts) > 0 { 261 active, changed, err = rc.ruleProcessPoints(id, pts) 262 if err != nil { 263 log.Println("Error processing rule point:", err) 264 } 265 266 if !changed { 267 return 268 } 269 } else { 270 // send a schedule trigger through just in case someone changed a 271 // schedule condition 272 active, _, err = rc.ruleProcessPoints(rc.config.ID, data.Points{{ 273 Time: time.Now(), 274 Type: data.PointTypeTrigger, 275 }}) 276 277 if err != nil { 278 log.Println("Error processing rule point:", err) 279 } 280 } 281 } 282 283 if active { 284 err := rc.ruleRunActions(rc.config.Actions, id) 285 if err != nil { 286 log.Println("Error running rule actions:", err) 287 } 288 289 err = rc.ruleInactiveActions(rc.config.ActionsInactive) 290 if err != nil { 291 log.Println("Error running rule inactive actions:", err) 292 } 293 } else { 294 err := rc.ruleRunActions(rc.config.ActionsInactive, id) 295 if err != nil { 296 log.Println("Error running rule actions:", err) 297 } 298 299 err = rc.ruleInactiveActions(rc.config.Actions) 300 if err != nil { 301 log.Println("Error running rule inactive actions:", err) 302 } 303 } 304 } 305 306 done: 307 for { 308 select { 309 case <-rc.stop: 310 break done 311 case pts := <-rc.newRulePoints: 312 // make sure the point is in a condition before we run the rule 313 // otherwise, we can get into a loop 314 found := false 315 for _, c := range rc.config.Conditions { 316 if c.ConditionType != data.PointValuePointValue { 317 continue 318 } 319 if c.NodeID == pts.ID { 320 found = true 321 break 322 } 323 } 324 325 if found { 326 // found a condition that matches the point coming in, run the rule 327 run(pts.ID, pts.Points) 328 } 329 330 case <-scheduleTicker.C: 331 run(rc.config.ID, data.Points{{ 332 Time: time.Now(), 333 Type: data.PointTypeTrigger, 334 }}) 335 336 case pts := <-rc.newPoints: 337 err := data.MergePoints(pts.ID, pts.Points, &rc.config) 338 if err != nil { 339 log.Println("error merging rule points:", err) 340 } 341 342 if rc.hasSchedule() { 343 scheduleTicker = time.NewTicker(scheduleTickTime) 344 } else { 345 scheduleTicker.Stop() 346 } 347 348 run("", nil) 349 350 case pts := <-rc.newEdgePoints: 351 err := data.MergeEdgePoints(pts.ID, pts.Parent, pts.Points, &rc.config) 352 if err != nil { 353 log.Println("error merging rule edge points:", err) 354 } 355 356 run("", nil) 357 } 358 } 359 360 return rc.upSub.Unsubscribe() 361 } 362 363 // Stop sends a signal to the Run function to exit 364 func (rc *RuleClient) Stop(_ error) { 365 close(rc.stop) 366 } 367 368 // Points is called by the Manager when new points for this 369 // node are received. 370 func (rc *RuleClient) Points(nodeID string, points []data.Point) { 371 rc.newPoints <- NewPoints{nodeID, "", points} 372 } 373 374 // EdgePoints is called by the Manager when new edge points for this 375 // node are received. 376 func (rc *RuleClient) EdgePoints(nodeID, parentID string, points []data.Point) { 377 rc.newEdgePoints <- NewPoints{nodeID, parentID, points} 378 } 379 380 // sendPoint sets origin to the rule node 381 func (rc *RuleClient) sendPoint(id string, point data.Point) error { 382 if id != rc.config.ID { 383 // we must set origin as we are sending a point to something 384 // other than the client root node 385 // TODO: it might be good to somehow move this into the 386 // client manager, so that clients don't need to worry about 387 // setting Origin 388 point.Origin = rc.config.ID 389 } 390 return SendNodePoint(rc.nc, id, point, false) 391 } 392 393 func (rc *RuleClient) hasSchedule() bool { 394 for _, c := range rc.config.Conditions { 395 if c.ConditionType == data.PointValueSchedule { 396 return true 397 } 398 } 399 return false 400 } 401 402 func (rc *RuleClient) processError(errS string) { 403 if errS != "" { 404 // always set rule error to the last error we encounter 405 if errS != rc.config.Error { 406 p := data.Point{ 407 Type: data.PointTypeError, 408 Time: time.Now(), 409 Text: errS, 410 } 411 412 err := rc.sendPoint(rc.config.ID, p) 413 if err != nil { 414 log.Println("Rule error sending point:", err) 415 } else { 416 rc.config.Error = errS 417 } 418 } 419 } else { 420 // check if any other errors still exist 421 found := "" 422 423 for _, c := range rc.config.Conditions { 424 if c.Error != "" { 425 found = c.Error 426 break 427 } 428 } 429 430 for _, a := range rc.config.Actions { 431 if a.Error != "" { 432 found = a.Error 433 break 434 } 435 } 436 437 for _, a := range rc.config.ActionsInactive { 438 if a.Error != "" { 439 found = a.Error 440 break 441 } 442 } 443 444 if found != rc.config.Error { 445 p := data.Point{ 446 Type: data.PointTypeError, 447 Time: time.Now(), 448 Text: found, 449 } 450 451 err := rc.sendPoint(rc.config.ID, p) 452 if err != nil { 453 log.Println("Rule error sending point:", err) 454 } else { 455 rc.config.Error = found 456 } 457 } 458 } 459 } 460 461 // ruleProcessPoints runs points through a rules conditions and and updates condition 462 // and rule active status. Returns true if point was processed and active is true. 463 // Currently, this function only processes the first point that matches -- this should 464 // handle all current uses. 465 func (rc *RuleClient) ruleProcessPoints(nodeID string, points data.Points) (bool, bool, error) { 466 for _, p := range points { 467 for i, c := range rc.config.Conditions { 468 var active bool 469 var errorActive bool 470 471 processError := func(err error) { 472 errorActive = true 473 errS := err.Error() 474 if c.Error != errS { 475 p := data.Point{ 476 Type: data.PointTypeError, 477 Time: time.Now(), 478 Text: errS, 479 } 480 481 log.Printf("Rule cond error %v:%v:%v\n", rc.config.Description, c.Description, err) 482 err := rc.sendPoint(c.ID, p) 483 if err != nil { 484 log.Println("Rule error sending point:", err) 485 } else { 486 rc.config.Conditions[i].Error = errS 487 } 488 } 489 rc.processError(errS) 490 } 491 492 switch c.ConditionType { 493 case data.PointValuePointValue: 494 if c.NodeID != "" && c.NodeID != nodeID { 495 continue 496 } 497 498 if c.PointKey != "" && c.PointKey != p.Key { 499 continue 500 } 501 502 if c.PointType != "" && c.PointType != p.Type { 503 continue 504 } 505 // conditions match, so check value 506 switch c.ValueType { 507 case data.PointValueNumber: 508 switch c.Operator { 509 case data.PointValueGreaterThan: 510 active = p.Value > c.Value 511 case data.PointValueLessThan: 512 active = p.Value < c.Value 513 case data.PointValueEqual: 514 active = p.Value == c.Value 515 case data.PointValueNotEqual: 516 active = p.Value != c.Value 517 } 518 case data.PointValueText: 519 switch c.Operator { 520 case data.PointValueEqual: 521 case data.PointValueNotEqual: 522 case data.PointValueContains: 523 } 524 case data.PointValueOnOff: 525 condValue := c.Value != 0 526 pointValue := p.Value != 0 527 active = condValue == pointValue 528 default: 529 processError(fmt.Errorf("unknown value type: %v", c.ValueType)) 530 } 531 case data.PointValueSchedule: 532 if p.Type != data.PointTypeTrigger { 533 continue 534 } 535 536 weekdays := []time.Weekday{} 537 for i, v := range c.Weekdays { 538 if v { 539 weekdays = append(weekdays, time.Weekday(i)) 540 } 541 } 542 sched := newSchedule(c.Start, c.End, weekdays, c.Dates) 543 544 var err error 545 active, err = sched.activeForTime(p.Time) 546 if err != nil { 547 processError(fmt.Errorf("Error parsing schedule: %w", err)) 548 continue 549 } 550 } 551 552 if active != c.Active { 553 // update condition 554 p := data.Point{ 555 Type: data.PointTypeActive, 556 Time: time.Now(), 557 Value: data.BoolToFloat(active), 558 } 559 560 err := rc.sendPoint(c.ID, p) 561 if err != nil { 562 log.Println("Rule error sending point:", err) 563 } 564 565 rc.config.Conditions[i].Active = active 566 } 567 568 if !errorActive && c.Error != "" { 569 p := data.Point{ 570 Type: data.PointTypeError, 571 Time: time.Now(), 572 Text: "", 573 } 574 575 err := rc.sendPoint(c.ID, p) 576 if err != nil { 577 log.Println("Rule error sending point:", err) 578 } else { 579 rc.config.Conditions[i].Error = "" 580 } 581 rc.processError("") 582 } 583 } 584 } 585 586 allActive := true 587 activeConditionCount := 0 588 589 for _, c := range rc.config.Conditions { 590 if !c.Active && !c.Disabled { 591 allActive = false 592 break 593 } 594 if c.Active && !c.Disabled { 595 activeConditionCount++ 596 } 597 } 598 599 if activeConditionCount == 0 && allActive { 600 allActive = false 601 } 602 603 changed := false 604 605 if allActive != rc.config.Active { 606 p := data.Point{ 607 Type: data.PointTypeActive, 608 Time: time.Now(), 609 Value: data.BoolToFloat(allActive), 610 } 611 612 err := rc.sendPoint(rc.config.ID, p) 613 if err != nil { 614 log.Println("Rule error sending point:", err) 615 } 616 changed = true 617 618 rc.config.Active = allActive 619 } 620 621 return allActive, changed, nil 622 } 623 624 // ruleRunActions runs rule actions 625 func (rc *RuleClient) ruleRunActions(actions []Action, triggerNodeID string) error { 626 for i, a := range actions { 627 if a.Disabled { 628 continue 629 } 630 631 errorActive := false 632 633 processError := func(err error) { 634 errorActive = true 635 errS := err.Error() 636 if a.Error != errS { 637 p := data.Point{ 638 Type: data.PointTypeError, 639 Time: time.Now(), 640 Text: errS, 641 } 642 643 log.Printf("Rule action error %v:%v:%v\n", rc.config.Description, a.Description, err) 644 err := rc.sendPoint(a.ID, p) 645 if err != nil { 646 log.Println("Rule error sending point:", err) 647 } else { 648 actions[i].Error = errS 649 } 650 } 651 rc.processError(errS) 652 } 653 654 switch a.Action { 655 case data.PointValueSetValue: 656 if a.NodeID == "" { 657 processError(fmt.Errorf("Error, node action nodeID must be set")) 658 break 659 } 660 661 if a.PointType == "" { 662 processError(fmt.Errorf("Error, node action point type must be set")) 663 break 664 } 665 666 p := data.Point{ 667 Time: time.Now(), 668 Type: a.PointType, 669 Key: a.PointKey, 670 Value: a.Value, 671 Text: a.ValueText, 672 Origin: a.ID, 673 } 674 675 err := rc.sendPoint(a.NodeID, p) 676 if err != nil { 677 log.Println("Error sending rule action point:", err) 678 } 679 case data.PointValueNotify: 680 // get node that fired the rule 681 nodes, err := GetNodes(rc.nc, "none", triggerNodeID, "", false) 682 if err != nil { 683 processError(err) 684 break 685 } 686 687 if len(nodes) < 1 { 688 processError(fmt.Errorf("trigger node not found")) 689 break 690 } 691 692 triggerNode := nodes[0] 693 694 triggerNodeDesc := triggerNode.Desc() 695 696 n := data.Notification{ 697 ID: uuid.New().String(), 698 SourceNode: a.NodeID, 699 Message: rc.config.Description + " fired at " + triggerNodeDesc, 700 } 701 702 // TODO this notify code needs to be reworked 703 d, err := n.ToPb() 704 705 if err != nil { 706 return err 707 } 708 709 err = rc.nc.Publish("node."+rc.config.ID+".not", d) 710 711 if err != nil { 712 return err 713 } 714 case data.PointValuePlayAudio: 715 f, err := os.Open(a.PointFilePath) 716 if err != nil { 717 log.Fatal(err) 718 } 719 defer f.Close() 720 721 d := wav.NewDecoder(f) 722 d.ReadInfo() 723 724 format := d.Format() 725 726 if format.SampleRate < 8000 { 727 log.Println("Rule action: invalid wave file sample rate:", format.SampleRate) 728 continue 729 } 730 731 channelNum := strconv.Itoa(a.PointChannel) 732 sampleRate := strconv.Itoa(format.SampleRate) 733 734 go func() { 735 stderr, err := exec.Command("speaker-test", "-D"+a.PointDevice, "-twav", "-w"+a.PointFilePath, "-c5", "-s"+channelNum, "-r"+sampleRate).CombinedOutput() 736 if err != nil { 737 log.Println("Play audio error:", err) 738 log.Printf("Audio stderr: %s\n", stderr) 739 } 740 }() 741 default: 742 processError(fmt.Errorf("Uknown rule action: %v", a.Action)) 743 } 744 745 p := data.Point{ 746 Type: data.PointTypeActive, 747 Value: 1, 748 } 749 err := rc.sendPoint(a.ID, p) 750 if err != nil { 751 log.Println("Error sending rule action point:", err) 752 } 753 754 actions[i].Active = true 755 756 if !errorActive && a.Error != "" { 757 p := data.Point{ 758 Type: data.PointTypeError, 759 Time: time.Now(), 760 Text: "", 761 } 762 763 err := rc.sendPoint(a.ID, p) 764 if err != nil { 765 log.Println("Rule error sending point:", err) 766 } else { 767 actions[i].Error = "" 768 } 769 rc.processError("") 770 } 771 772 } 773 return nil 774 } 775 776 func (rc *RuleClient) ruleInactiveActions(actions []Action) error { 777 for i, a := range actions { 778 if a.Disabled { 779 continue 780 } 781 782 p := data.Point{ 783 Type: data.PointTypeActive, 784 Value: 0, 785 } 786 err := rc.sendPoint(a.ID, p) 787 if err != nil { 788 log.Println("Error sending rule action point:", err) 789 } 790 actions[i].Active = false 791 } 792 return nil 793 }