├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.adoc ├── cmd ├── lgresu_mon │ ├── lgresu_actors.go │ ├── lgresu_mon.go │ └── lgresu_mon_test.go └── lgresu_sim │ └── lgresu_sim.go ├── datarecorder ├── datarecorder.go └── datarecorder_test.go ├── doc ├── LGResuMon.adoc ├── LGResuMon.pdf ├── RPISetup.adoc ├── firefox_json_lgresu.png ├── lg_resu_dashboard_phone.png ├── lg_resu_mon_hardware_1200x800.jpg ├── node_red_dashboard.png ├── node_red_dashboard_install.png ├── node_red_edit_ip_addr.png ├── node_red_import.png └── node_red_manage_palette.png ├── lgresustatus ├── lgresustatus.go └── lgresustatus_test.go └── script ├── can_stats.sh ├── keep_alive.sh ├── lg_resu_dashboard.json ├── start_interface.sh └── start_lg_resu_mon.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | *~ 7 | *.log 8 | cmd/lgresu_mon/lgresu_mon 9 | cmd/lgresu_sim/lgresu_sim 10 | cmd/lgresu_mon/data 11 | doc/RPISetup.pdf 12 | 13 | dist/ 14 | 15 | # Test binary, build with `go test -c` 16 | *.test 17 | 18 | # Output of the go coverage tool, specifically when used with LiteIDE 19 | *.out 20 | 21 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 22 | .glide/ 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | # request VM instead of container (default) 4 | sudo: required 5 | dist: trusty 6 | 7 | env: 8 | # encrypted form of COVERALLS_TOKEN API key 9 | - secure: "Ok3RNguO3Ar1ItaLMS5XwOfPFwUcgdpyUwQAejvf/4NFCEm9xTsaMxS7xQaKJ5LZ+leS9ulgFbITqY+r2kdPhjWI/SUsP15KtDWnaIqrVjahuTYkGmK2N72VvmNXX3nGj6PUBjTb7/IMJWraPI/iKF2t39NfQCiBLTP9V66wiReB6P2R8NlP9BuXzwtOktwkaZc/yKy01xXlz+ZbpoLfYpg0+RH94aN9g4E7467D01KLgwSsiP3FsPcKEYmlG9AuxzRNeKdTGZiVJqj96VFdBYgLiLFQnlEF/avBeyPP/Nz8vJFJRJQuX0yW7gyKA2pUCqfPVogRbwp3WexJHOlTOCC65fxl/N7x9w94muTFEjjZ9ALz+xPToVSiNkw/zYTdqTAJP04AKGDsvmGZMqPPuK8wLqUzVMkQmfYmLLYBlzNHhLsinZBFYtqnL95NJ/SLGvVfIJuJ3HzMM2LGvt+0LWgVFNymx3atap3kPhjEUVs/9+FBNcu1gRAjttQg6evYJmSMw3Iijr6riJgVPTMjTJWGVUchCLmO9hVefxfvAysbUxyajaKCYBtYq+jBDkAnGQ4ztq5aL1W0b3CXSs2YlVRMoKEmznzCWJXIMR3IFbtaeHEoeky/mqzkCF3vX2JfLHQ+VL9OQokdnC1Ammds3SwuXLku9+PIzl9rXzhxziI=" 10 | 11 | go: 12 | - 1.10.x 13 | 14 | before_install: 15 | - go get github.com/sirupsen/logrus 16 | - go get github.com/brutella/can 17 | - go get github.com/google/go-cmp/cmp 18 | - go get github.com/gorilla/mux 19 | - go get golang.org/x/tools/cmd/cover 20 | - go get github.com/mattn/goveralls 21 | # create virtual CANBus interface vcan0 22 | - sudo modprobe vcan 23 | - sudo ip link add dev vcan0 type vcan 24 | - sudo ip link set up vcan0 25 | - ifconfig vcan0 26 | 27 | script: 28 | - go test -v -covermode=count -coverprofile=coverage.out -tags test ./... 29 | # upload coverage.out report 30 | - $HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN 31 | - export GOARCH=arm; make install 32 | 33 | deploy: 34 | provider: releases 35 | api_key: 36 | secure: "Bw6uqasy6W8aFW/idrgvCjPt/HPD/VtTfyvaciOLYKLh7WGumhC6z+zN2knPkSFjrF3B/0WOjfoOcxTNBDTSxq8IUrhvQgrD2tgPx+9zXvy0Ch1DKhUpNPdiis7VM/O5fG9Uq7dylV5XrPmlwqFOkTl/xTk3fKH7lVDOSX07bTnHwdvFkpRBH8RwhWuYqmtJKmfK1CYGN1wc8FekjADGPI7YrZa3ccNyDyNLU4JH4qjLibIWvY7p5GJHMR8K9MhD585fUB6l0WK3ZMezf1GyTs7TehqvwUhIdt6BphA28UNTkHu82k4Jy/1zZlrImLhjxLT+AAInVW/Utjlze4IDSlBR2dAhm50G7tzec2BgNhxrMRV/BzpPMK7HLwE9ehXwZr2THEggRIBLyKQVkqfDdEDLezL77/cCzR7lpuY8L0l35XEOPCw8765QAhZDhbIQvivycJlEyV30fN/UaPtgoBDIHLLIbM4zF+ljzyHCoHSzKiS/Dr9vss03V5Ojbtq7MV1o6rcwcuuTk/PjNffAA5WgPmTm9hfe2qsov5IlgtMcagVoEDq5InFurUmMLtlyZO9wJAkhRho4Fnfny6y9lsKU/UJvPMZtGfvo732g9WGj1k7wuqHobR7hh2Dz3ZoIqlLbjj+BW3A4c0DLGp0ORFm6J99GoAvut8lbrGd3ou4=" 37 | file: "dist/lgresu-*-linux-*.tar.gz" 38 | file_glob: "true" 39 | skip_cleanup: true 40 | on: 41 | tags: true 42 | 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #PREFIX is environment variable, but if it is not set, then set default value 2 | ifeq ($(PREFIX),) 3 | PREFIX := dist 4 | endif 5 | 6 | ARCH := $(shell go env GOARCH) 7 | VERSION := $(shell git describe) 8 | 9 | all: install 10 | 11 | 12 | # embedding version number: 13 | # 1) https://www.reddit.com/r/golang/comments/4cpi2y/question_where_to_keep_the_version_number_of_a_go/ 14 | # 2) https://gist.github.com/TheHippo/7e4d9ec4b7ed4c0d7a39839e6800cc16 15 | cmd/lgresu_mon/lgresu_mon: dep cmd/lgresu_mon/lgresu_mon.go cmd/lgresu_mon/lgresu_actors.go 16 | ( cd $(dir $@); go build -ldflags="-X main.version=${VERSION}" lgresu_mon.go lgresu_actors.go ) 17 | 18 | .PHONY: dep 19 | dep: 20 | go get github.com/sirupsen/logrus 21 | go get github.com/brutella/can 22 | go get github.com/google/go-cmp/cmp 23 | go get github.com/gorilla/mux 24 | go get golang.org/x/tools/cmd/cover 25 | go get github.com/mattn/goveralls 26 | 27 | doc: doc/LGResuMon.pdf doc/RPISetup.pdf 28 | 29 | doc/%.pdf: doc/%.adoc 30 | asciidoctor-pdf $< 31 | 32 | 33 | .PHONY: clean 34 | clean: 35 | -rm cmd/lgresu_mon/lgresu_mon 36 | -rm -rf dist/* 37 | 38 | install: cmd/lgresu_mon/lgresu_mon 39 | install -d $(PREFIX)/lgresu-$(VERSION)/bin/ 40 | install -d $(PREFIX)/lgresu-$(VERSION)/doc/ 41 | install -m 644 doc/LGResuMon.pdf $(PREFIX)/lgresu-$(VERSION)/doc 42 | install -d $(PREFIX)/lgresu-$(VERSION)/script/ 43 | install -m 755 cmd/lgresu_mon/lgresu_mon $(PREFIX)/lgresu-$(VERSION)/bin/lg_resu_mon 44 | install -d $(PREFIX)/lgresu-$(VERSION)/script/ 45 | install -m 755 script/can_stats.sh $(PREFIX)/lgresu-$(VERSION)/script 46 | install -m 755 script/keep_alive.sh $(PREFIX)/lgresu-$(VERSION)/script 47 | install -m 755 script/start_interface.sh $(PREFIX)/lgresu-$(VERSION)/script 48 | install -m 644 script/lg_resu_dashboard.json $(PREFIX)/lgresu-$(VERSION)/script 49 | install -m 755 script/start_lg_resu_mon.sh $(PREFIX)/lgresu-$(VERSION) 50 | tar -c -v -z -f dist/lgresu-$(VERSION)-linux-$(ARCH).tar.gz -C dist lgresu-$(VERSION) 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | = LG Resu 10 LV Monitor image:https://travis-ci.org/jens18/lgresu.svg["Build Status", link="https://travis-ci.org/jens18/lgresu"] image:https://coveralls.io/repos/github/jens18/lgresu/badge.svg?branch=master["Coverage Status", link="https://coveralls.io/github/jens18/lgresu?branch=master"] image:https://godoc.org/github.com/jens18/lgresu/lgresustatus?status.svg["GoDoc", link="https://godoc.org/github.com/jens18/lgresu/lgresustatus"] image:https://goreportcard.com/badge/github.com/jens18/lgresu["Go Report Card", link="https://goreportcard.com/report/github.com/jens18/lgresu"] 2 | 3 | == Introduction 4 | 5 | The http://www.lgchem.com/global/ess/ess/product-detail-PDEC0001[LG Resu 10 LV] Energy Storage System is a very compact, residential Lithium Ion battery made by LG Chem. The lgresu monitoring system can be adapted to work with other CANBus based Lithium Ion Battery Management Systems (BMS)(for example http://discoveraes.com/[Discover AES] batteries). 6 | 7 | == Monitoring 8 | 9 | The lgresu project provides a server and web based monitoring system to visualize the main LG Resu 10 LV BMS metrics such as: 10 | 11 | * State Of Charge (SOC) 12 | * State Of Health (SOH) 13 | * Voltage 14 | * Current 15 | * Temperature. 16 | 17 | image::doc/lg_resu_dashboard_phone.png[Screenshot,375,660] 18 | 19 | == Operation 20 | 21 | The lgresu server also supplies the LG Resu 10 LV battery with a 'keep-alive' CANBus message which enables the use of a 22 | 48VDC inverter without a CANBus connection to the LG Resu 10 LV. 23 | 24 | == Hardware 25 | 26 | lgresu software requires a Raspberry PI equipped with a CANBus module connected to a LG Resu 10 battery: 27 | 28 | image::doc/lg_resu_mon_hardware_1200x800.jpg[] 29 | 30 | More details about the hardware can be found in link:https://github.com/jens18/lgresu/blob/master/doc/LGResuMon.adoc[LGResuMon.adoc]. 31 | 32 | This solution can coexist with other products for integration of the LG Resu 10 battery with an inverter 33 | (Schneider Conext Bridge, Victron ESS, SMA Sunny Island, ...). 34 | 35 | It is also possible to connect this hardware to a car CANBus with an ODB2 to RJ45 adapter cable. 36 | 37 | == Documentation 38 | 39 | lg_resu_mon server: https://github.com/jens18/lgresu/blob/master/doc/LGResuMon.adoc + 40 | lg_resu_dashboard web application: https://github.com/jens18/lgresu/blob/master/doc/LGResuMon.adoc + 41 | lgresustatus API: https://godoc.org/github.com/jens18/lgresu/lgresustatus + 42 | datarecorder API: https://godoc.org/github.com/jens18/lgresu/datarecorder + 43 | Raspberry PI configuration: https://github.com/jens18/lgresu/blob/master/doc/RPISetup.adoc + 44 | Solar/Grid Hybrid System notes: https://jenska.gitlab.io/jknotes/posts/hybrid_grid/ + 45 | Discover AES CANBus specification: http://discoveraes.com/wp-content/uploads/2017/12/AEBus-Communication-Protocol-Specification.pdf 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /cmd/lgresu_mon/lgresu_actors.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Jens Kaemmerer. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //+build test 16 | 17 | package main 18 | 19 | import ( 20 | "encoding/json" 21 | "fmt" 22 | "github.com/brutella/can" 23 | rs "github.com/jens18/lgresu/lgresustatus" 24 | log "github.com/sirupsen/logrus" 25 | "net/http" 26 | "os" 27 | "time" 28 | ) 29 | 30 | type DatarecorderIf interface { 31 | WriteToDatafile(time.Time, string) 32 | } 33 | 34 | const ( 35 | keepAliveInterval int = 20 36 | ) 37 | 38 | type CanbusIf interface { 39 | Publish(can.Frame) error 40 | Disconnect() error 41 | } 42 | 43 | // terminateMonitor receive operating system signal messages (SIGTERM, SIGKILL) via osSigChan and 44 | // issue a message via the termSigChan before disconnecting from the CANBus and terminating the server. 45 | func terminateMonitor(osSigChan <-chan os.Signal, termSigChan chan<- bool, bus CanbusIf) { 46 | 47 | select { 48 | case <-osSigChan: 49 | fmt.Printf("main: received SIGKILL/SIGTERM\n") 50 | // terminate sendKeepAlive goroutine 51 | termSigChan <- true 52 | bus.Disconnect() 53 | time.Sleep(time.Second * 1) 54 | os.Exit(1) 55 | } 56 | } 57 | 58 | // decodeCanFrame returns a function that implements the can.Handler interface. 59 | func decodeCanFrame(recordEmitChan chan<- rs.LgResuStatus) func(can.Frame) { 60 | // https://www.calhoun.io/5-useful-ways-to-use-closures-in-go/ 61 | 62 | // lgResu holds the current state of metrics from the LG Resu 10. 63 | // Every update message received will update only parts of LgResuStatus (ie. Soc + Soh but not Voltage). 64 | // Only the implementation of the can.Handler interface has access to 65 | // lgResu. 66 | lgResu := &rs.LgResuStatus{} 67 | 68 | return func(frm can.Frame) { 69 | lgResu.DecodeLgResuCanbusMessage(frm.ID, frm.Data[:]) 70 | 71 | // send the latest lgResu status update (and block (briefly until BrokerRecord has read lgResu)) 72 | recordEmitChan <- *lgResu 73 | } 74 | } 75 | 76 | // writeRecord writes a new LgResuStatus record to a CSV datafile every minute. 77 | func writeRecord(writeSigChan chan<- bool, 78 | recordWriteChan <-chan rs.LgResuStatus, 79 | dataRecorder DatarecorderIf, 80 | recordingFrequency int) { 81 | 82 | // dr := dr.NewDatarecorder(dataDirRoot, ".csv", retentionPeriod, lgResu.CsvRecordHeader()) 83 | 84 | for { 85 | select { 86 | case <-time.After(time.Duration(recordingFrequency) * time.Second): 87 | writeSigChan <- true 88 | lgResu := <-recordWriteChan 89 | 90 | // convert lgResu to CSV record with timestamp 91 | now := time.Now() 92 | 93 | lgResuCsvRecord := lgResu.CsvRecord(now) 94 | 95 | log.Infof("WriteRecord: %s ", lgResuCsvRecord) 96 | 97 | dataRecorder.WriteToDatafile(now, lgResuCsvRecord) 98 | } 99 | } 100 | 101 | } 102 | 103 | // brokerRecord receives LgResuStatus objects at a higher frequency (approx. once per second) 104 | // and responds to lower frequency requests to either persist the LgResuStatus object (writeSigChan to 105 | // signal a request, recordWriteChan to send the LgResuStatus object) or to respond to a pending 106 | // HTTP request (httpSignChan to signal a request, httpWriteChan to send the LgResuStatus object). 107 | func brokerRecord(recordEmitChan <-chan rs.LgResuStatus, 108 | writeSigChan <-chan bool, recordWriteChan chan<- rs.LgResuStatus, 109 | httpSigChan <-chan bool, httpWriteChan chan<- rs.LgResuStatus) { 110 | 111 | lgResu := rs.LgResuStatus{} 112 | 113 | for { 114 | select { 115 | case lgResu = <-recordEmitChan: 116 | log.Debugf("BrokerRecord(): received %v\n", lgResu) 117 | case <-writeSigChan: 118 | log.Debugf("BrokerRecord(): received writeSigChan\n") 119 | recordWriteChan <- lgResu 120 | case <-httpSigChan: 121 | log.Debugf("BrokerRecord(): received httpSigChan\n") 122 | httpWriteChan <- lgResu 123 | } 124 | } 125 | } 126 | 127 | // sendKeepAlive send keep-alive messages in KeepAliveInterval second intervals until it receives termination message. 128 | func sendKeepAlive(c <-chan bool, bus CanbusIf, interval int) { 129 | lgResu := &rs.LgResuStatus{} 130 | frm := can.Frame{} 131 | 132 | for { 133 | select { 134 | case <-c: 135 | log.Debugf("sendKeepAlive: received termination message\n") 136 | return 137 | case <-time.After(time.Duration(interval) * time.Second): 138 | log.Infof("sendKeepAlive: %d sec time out, sending keep-alive\n", interval) 139 | 140 | id, data := lgResu.CreateKeepAliveMessage() 141 | 142 | log.Debugf("sendKeepAlive: %#4x # % -24X \n", id, data) 143 | 144 | frm.ID = id 145 | frm.Length = uint8(len(data)) 146 | // copy must be 'tricked' into treating the array as a slice 147 | copy(frm.Data[:], data[0:8]) 148 | 149 | bus.Publish(frm) 150 | } 151 | } 152 | } 153 | 154 | // Index processes HTTP requests and generates a JSON response. 155 | func Index(httpSigChan chan<- bool, recordHttpChan <-chan rs.LgResuStatus) func(http.ResponseWriter, *http.Request) { 156 | return func(w http.ResponseWriter, r *http.Request) { 157 | 158 | var lgResu rs.LgResuStatus 159 | 160 | // signal a request has arrived (and block) 161 | httpSigChan <- true 162 | 163 | // receive the latest lgResu status update (and block) 164 | lgResu = <-recordHttpChan 165 | log.Infof("Index: lgResu = %+v \n", lgResu) 166 | 167 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 168 | json.NewEncoder(w).Encode(lgResu) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /cmd/lgresu_mon/lgresu_mon.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Jens Kaemmerer. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //+build !test 16 | 17 | package main 18 | 19 | import ( 20 | "flag" 21 | "fmt" 22 | "github.com/brutella/can" 23 | "github.com/gorilla/mux" 24 | dr "github.com/jens18/lgresu/datarecorder" 25 | rs "github.com/jens18/lgresu/lgresustatus" 26 | log "github.com/sirupsen/logrus" 27 | "net" 28 | "net/http" 29 | "os" 30 | "os/signal" 31 | ) 32 | 33 | var ( 34 | version = "undefined" 35 | ) 36 | 37 | func main() { 38 | log.Infof("lgresu_mon:\n") 39 | 40 | // default value is the virtual CANBus interface: vcan0 41 | i := flag.String("if", "vcan0", "network interface name") 42 | logLevel := flag.String("d", "info", "log level: debug, info, warn, error") 43 | port := flag.String("p", "9090", "port number") 44 | dataDirRoot := flag.String("dr", "/opt/lgresu", "root directory for metric datafiles") 45 | retentionPeriod := flag.Int("r", 7, "metric datafile retention period in days") 46 | v := flag.Bool("v", false, "version number") 47 | 48 | flag.Parse() 49 | 50 | if *v == true { 51 | fmt.Printf("version number: %s \n", version) 52 | os.Exit(1) 53 | } 54 | 55 | if len(*i) == 0 { 56 | flag.Usage() 57 | os.Exit(1) 58 | } 59 | 60 | switch *logLevel { 61 | case "info": 62 | log.SetLevel(log.InfoLevel) 63 | case "debug": 64 | log.SetLevel(log.DebugLevel) 65 | case "warn": 66 | log.SetLevel(log.WarnLevel) 67 | case "error": 68 | log.SetLevel(log.ErrorLevel) 69 | default: 70 | flag.Usage() 71 | os.Exit(1) 72 | } 73 | 74 | iface, err := net.InterfaceByName(*i) 75 | 76 | if err != nil { 77 | log.Fatalf("lgresu_mon: Could not find network interface %s (%v)", *i, err) 78 | } 79 | 80 | // bind to socket 81 | conn, err := can.NewReadWriteCloserForInterface(iface) 82 | 83 | if err != nil { 84 | log.Fatal(err) 85 | } 86 | 87 | bus := can.NewBus(conn) 88 | 89 | // channel to receive os.Kill/SIGKILL(9) and os.Interrupt/SIGTERM(15) notifications 90 | osSigChan := make(chan os.Signal) 91 | // channel to terminate sendKeepAlive goroutine 92 | termSigChan := make(chan bool) 93 | 94 | // channel to signal request from WriteRecord to BrokerRecord 95 | writeSigChan := make(chan bool) 96 | 97 | // channel to signal request from Index to BrokerRecord 98 | httpSigChan := make(chan bool) 99 | 100 | // channel to receive data from BrokerRecord to WriteRecord 101 | recordWriteChan := make(chan rs.LgResuStatus) 102 | 103 | // channel to receive data from BrokerRecord to Index 104 | recordHttpChan := make(chan rs.LgResuStatus) 105 | 106 | recordEmitChan := make(chan rs.LgResuStatus) 107 | 108 | signal.Notify(osSigChan, os.Interrupt) 109 | signal.Notify(osSigChan, os.Kill) 110 | 111 | // terminate sendKeepAlive and CANBus 112 | go terminateMonitor(osSigChan, termSigChan, bus) 113 | 114 | // send keep-alive message to LG Resu 10 115 | go sendKeepAlive(termSigChan, bus, keepAliveInterval) 116 | 117 | // respond to record requests and receive new records 118 | go brokerRecord(recordEmitChan, writeSigChan, recordWriteChan, httpSigChan, recordHttpChan) 119 | 120 | // receive update messages from LG Resu 10 121 | bus.SubscribeFunc(decodeCanFrame(recordEmitChan)) 122 | go bus.ConnectAndPublish() 123 | 124 | dr := dr.NewDatarecorder(*dataDirRoot, ".csv", *retentionPeriod, rs.CsvRecordHeader()) 125 | 126 | // write record to datafile (60 second recordingFrequency) 127 | go writeRecord(writeSigChan, recordWriteChan, dr, 60) 128 | 129 | router := mux.NewRouter().StrictSlash(true) 130 | 131 | router.PathPrefix("/data").Handler(http.StripPrefix("/data", http.FileServer(http.Dir("data/")))) 132 | router.HandleFunc("/", Index(httpSigChan, recordHttpChan)) 133 | 134 | log.Fatal(http.ListenAndServe(":"+*port, router)) 135 | } 136 | -------------------------------------------------------------------------------- /cmd/lgresu_mon/lgresu_mon_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Jens Kaemmerer. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "encoding/json" 19 | "github.com/brutella/can" 20 | rs "github.com/jens18/lgresu/lgresustatus" 21 | log "github.com/sirupsen/logrus" 22 | "io/ioutil" 23 | "net" 24 | "net/http" 25 | "net/http/httptest" 26 | "os/exec" 27 | "syscall" 28 | "testing" 29 | "time" 30 | ) 31 | 32 | type MockDatarecorder struct { 33 | Cnt int 34 | } 35 | 36 | func (d *MockDatarecorder) WriteToDatafile(currentTime time.Time, record string) { 37 | d.Cnt++ 38 | } 39 | 40 | type MockCanbus struct { 41 | PublishCnt int 42 | DisconnectCnt int 43 | } 44 | 45 | func (c *MockCanbus) Publish(can.Frame) error { 46 | c.PublishCnt++ 47 | return nil 48 | } 49 | 50 | func (c *MockCanbus) Disconnect() error { 51 | c.DisconnectCnt++ 52 | return nil 53 | } 54 | 55 | // canbusTestMessages contains test messages generated by the LG Resu 10 LV battery BMS 56 | var canbusTestMessages = []struct { 57 | Identifier uint32 58 | Data [8]byte 59 | }{ 60 | // volt/amp/temp (LG Resu -> Inverter): 61 | { 62 | Identifier: rs.BMS_VOLT_AMP_TEMP, 63 | Data: [8]byte{0x4b, 0x15, 0xed, 0xff, 0xba, 0x00, 0x00, 0x00}, 64 | }, 65 | // ? (LG Resu -> Inverter): unknown message type (appears to be a constant) 66 | { 67 | Identifier: rs.BMS_SERIAL_NUM, 68 | Data: [8]byte{0x04, 0xc0, 0x00, 0x1f, 0x03, 0x00, 0x00, 0x00}, 69 | }, 70 | // configuration parameters (LG Resu -> Inverter): 71 | { 72 | Identifier: rs.BMS_LIMITS, 73 | Data: [8]byte{0x41, 0x02, 0x96, 0x03, 0x96, 0x03, 0x00, 0x00}, 74 | }, 75 | // state of charge/health (LG Resu -> Inverter): 76 | { 77 | Identifier: rs.BMS_SOC_SOH, 78 | Data: [8]byte{0x4d, 0x00, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00}, 79 | }, 80 | // warnings/alarms (LG Resu -> Inverter): 81 | { 82 | Identifier: rs.BMS_WARN_ALARM, 83 | Data: [8]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 84 | }, 85 | } 86 | 87 | func sendCanbusMessages(ifName string) { 88 | iface, err := net.InterfaceByName(ifName) 89 | 90 | if err != nil { 91 | log.Fatalf("sendCanbusMessages(): Could not find network interface %s (%v) \n", ifName, err) 92 | } 93 | 94 | // bind to socket 95 | conn, err := can.NewReadWriteCloserForInterface(iface) 96 | 97 | if err != nil { 98 | log.Fatalf("sendCanbusMessages(): Could not bind to socket %v \n", err) 99 | } 100 | 101 | bus := can.NewBus(conn) 102 | 103 | f := can.Frame{} 104 | 105 | for { 106 | // send all LG Resu 10 test messages in one block 107 | for _, tm := range canbusTestMessages { 108 | 109 | f.ID = tm.Identifier 110 | f.Length = uint8(len(tm.Data)) 111 | f.Data = tm.Data 112 | 113 | bus.Publish(f) 114 | 115 | log.Debugf("%#4x # % -24X \n", tm.Identifier, tm.Data) 116 | } 117 | 118 | // wait for 1 second 119 | <-time.After(time.Second * 1) 120 | } 121 | } 122 | 123 | func init() { 124 | // only log warning severity or above. 125 | log.SetLevel(log.WarnLevel) 126 | } 127 | 128 | // https://blog.questionable.services/article/testing-http-handlers-go/ 129 | 130 | // TestIndex tests if the HTTP request returns a JSON object. 131 | func TestIndex(t *testing.T) { 132 | 133 | // prepare lgResuStatus message 134 | lgResu := &rs.LgResuStatus{Soc: 78, Soh: 99, Voltage: 54.55, Current: -1, Temp: 26.1} 135 | 136 | // channel to signal request from Index 137 | httpSigChan := make(chan bool) 138 | // channel to send data to Index 139 | recordHttpChan := make(chan rs.LgResuStatus) 140 | 141 | // simulate BrokerRecord, issue exactly one lgResu object 142 | go func() { 143 | select { 144 | case <-httpSigChan: // wait for 'HTTP request pending' signal 145 | recordHttpChan <- *lgResu // send lrResu object 146 | } 147 | }() 148 | 149 | req, err := http.NewRequest("GET", "/", nil) 150 | if err != nil { 151 | t.Fatal(err) 152 | } 153 | 154 | rr := httptest.NewRecorder() 155 | handler := http.HandlerFunc(Index(httpSigChan, recordHttpChan)) 156 | 157 | // Call ServeHTTP method directly and pass in Request and ResponseRecorder. 158 | handler.ServeHTTP(rr, req) 159 | 160 | // Check the status code is what we expect. 161 | if status := rr.Code; status != http.StatusOK { 162 | t.Errorf("Index() handler returned wrong status code %v, expect %v \n", 163 | status, http.StatusOK) 164 | } 165 | 166 | // extract JSON message 167 | b := []byte(rr.Body.String()) 168 | 169 | // re-constructed lgResuStatus message 170 | var status rs.LgResuStatus 171 | err = json.Unmarshal(b, &status) 172 | if err != nil { 173 | t.Error(err) 174 | } 175 | 176 | // test Soc received against Soc send 177 | if status.Soc != lgResu.Soc { 178 | t.Errorf("Index handler() returned Soc value %v, expect Soc value %v \n", 179 | status.Soc, lgResu.Soc) 180 | } 181 | } 182 | 183 | // TestWriteRecord tests if a request for an lgResu object is made after 1 second. 184 | func TestWriteRecord(t *testing.T) { 185 | 186 | // prepare lgResuStatus message 187 | lgResu := &rs.LgResuStatus{Soc: 78, Soh: 99, Voltage: 54.55, Current: -1, Temp: 26.1} 188 | 189 | // channel to signal request from WriteRecord to BrokerRecord 190 | writeSigChan := make(chan bool) 191 | 192 | // channel to receive data from BrokerRecord to WriteRecord 193 | recordWriteChan := make(chan rs.LgResuStatus) 194 | 195 | // simulate BrokerRecord 196 | go func() { 197 | for { 198 | select { 199 | case <-writeSigChan: // wait for 'HTTP request pending' signal 200 | recordWriteChan <- *lgResu // send lrResu object 201 | } 202 | } 203 | }() 204 | 205 | dataRecorder := &MockDatarecorder{} 206 | 207 | // write record to datafile 208 | go writeRecord(writeSigChan, recordWriteChan, dataRecorder, 1) 209 | 210 | time.Sleep(3 * time.Second) 211 | 212 | if dataRecorder.Cnt != 2 { 213 | t.Errorf("writeRecorder() requested %d lgResu objects, expect %d requests \n", 214 | dataRecorder.Cnt, 2) 215 | } 216 | } 217 | 218 | // TestSendKeepAlive tests the periodical generation of keep alive messages. 219 | func TestSendKeepAlive(t *testing.T) { 220 | 221 | // channel to terminate sendKeepAlive goroutine 222 | termSigChan := make(chan bool) 223 | 224 | _ = termSigChan 225 | 226 | canbus := &MockCanbus{} 227 | 228 | // send keep-alive message to LG Resu 10 229 | go sendKeepAlive(termSigChan, canbus, 1) 230 | 231 | time.Sleep(3 * time.Second) 232 | 233 | if canbus.PublishCnt != 2 { 234 | t.Errorf("sendKeepAlive() generated %d keep alive messages, expect %d messages \n", 235 | canbus.PublishCnt, 2) 236 | } 237 | 238 | // stop the sendKeepAlive goroutine, there should be no additional keep alive messages 239 | termSigChan <- true 240 | 241 | time.Sleep(3 * time.Second) 242 | 243 | if canbus.PublishCnt != 2 { 244 | t.Errorf("sendKeepAlive() generated %d keep alive messages, expect %d messages \n", 245 | canbus.PublishCnt, 2) 246 | } 247 | } 248 | 249 | // TestDecodeCanFrame tests decoding of a CanBus frame in a closure. 250 | func TestDecodeCanFrame(t *testing.T) { 251 | 252 | recordEmitChan := make(chan rs.LgResuStatus) 253 | 254 | decoder := decodeCanFrame(recordEmitChan) 255 | 256 | frm := can.Frame{ 257 | ID: rs.BMS_SOC_SOH, 258 | Length: 8, 259 | Flags: 0, 260 | Res0: 0, 261 | Res1: 0, 262 | Data: [8]byte{0x4d, 0x00, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00}, 263 | } 264 | 265 | // simulate the canbus goroutine, executing decoder() as a normal function will cause a deadlock 266 | go decoder(frm) 267 | 268 | lgResu := rs.LgResuStatus{} 269 | lgResu = <-recordEmitChan 270 | 271 | if lgResu.Soc != 77 { 272 | t.Errorf("decodeCanFrame() produce Soc = %d, expect Soc = 77 \n", 273 | lgResu.Soc) 274 | } 275 | } 276 | 277 | // 278 | func TestBrokerRecord(t *testing.T) { 279 | 280 | // channel to signal request from WriteRecord to BrokerRecord 281 | writeSigChan := make(chan bool) 282 | 283 | // channel to signal request from Index to BrokerRecord 284 | httpSigChan := make(chan bool) 285 | 286 | // channel to receive data from BrokerRecord to WriteRecord 287 | recordWriteChan := make(chan rs.LgResuStatus) 288 | 289 | // channel to receive data from BrokerRecord to Index 290 | recordHttpChan := make(chan rs.LgResuStatus) 291 | 292 | recordEmitChan := make(chan rs.LgResuStatus) 293 | 294 | go brokerRecord(recordEmitChan, writeSigChan, recordWriteChan, httpSigChan, recordHttpChan) 295 | 296 | // prepare lgResuStatus message 297 | lgResu := &rs.LgResuStatus{Soc: 78, Soh: 99, Voltage: 54.55, Current: -1, Temp: 26.1} 298 | 299 | recordEmitChan <- *lgResu 300 | 301 | // send write request signal 302 | writeSigChan <- true 303 | // receive lgResu 304 | lgResuWrite := <-recordWriteChan 305 | 306 | // send http request signal 307 | httpSigChan <- true 308 | // receive lgResu 309 | lgResuHttp := <-recordHttpChan 310 | 311 | if lgResuWrite.Soc != 78 || lgResuHttp.Soc != 78 { 312 | t.Errorf("brokerRecord() produce Soc = %d, expect Soc = 78 \n", 313 | lgResuWrite.Soc) 314 | } 315 | } 316 | 317 | // TestIntegration tests if the HTTP request returns a JSON object. 318 | func TestIntegration(t *testing.T) { 319 | 320 | // start lgresu_mon server, combine stdout and stderr 321 | cmd := exec.Command("sh", "-c", 322 | "go run lgresu_mon.go lgresu_actors.go -if vcan0 -d debug -p 9090 -dr data -r 7 > lg_resu_mon.log 2>&1") 323 | // https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773 324 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 325 | 326 | // start in background 327 | err := cmd.Start() 328 | if err != nil { 329 | log.Fatal(err) 330 | } 331 | 332 | // generate CANBus messages 333 | go sendCanbusMessages("vcan0") 334 | 335 | // time to startup lg_resu_mon server 336 | time.Sleep(3 * time.Second) 337 | 338 | // HTTP request 339 | resp, err := http.Get("http://localhost:9090") 340 | if err != nil { 341 | t.Log(err) // handle error 342 | } 343 | defer resp.Body.Close() 344 | body, err := ioutil.ReadAll(resp.Body) 345 | 346 | log.Debugf("body = %s \n", body) 347 | 348 | // re-constructed lgResuStatus message 349 | var status rs.LgResuStatus 350 | err = json.Unmarshal(body, &status) 351 | if err != nil { 352 | t.Error(err) 353 | } 354 | 355 | // test Soc received against Soc send 356 | if status.Soc != 77 { 357 | t.Errorf("Index handler() returned Soc value %v, expect Soc value %v \n", 358 | status.Soc, 77) 359 | } 360 | 361 | // stop process when test is complete 362 | syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) 363 | } 364 | -------------------------------------------------------------------------------- /cmd/lgresu_sim/lgresu_sim.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/brutella/can" 7 | rs "github.com/jens18/lgresu/lgresustatus" 8 | "log" 9 | "net" 10 | "os" 11 | "os/signal" 12 | "time" 13 | ) 14 | 15 | // canbusTestMessages contains test messages generated by the LG Resu 10 LV battery BMS 16 | var canbusTestMessages = []struct { 17 | Identifier uint32 18 | Data [8]byte 19 | }{ 20 | // volt/amp/temp (LG Resu -> Inverter): 21 | { 22 | Identifier: rs.BMS_VOLT_AMP_TEMP, 23 | Data: [8]byte{0x4b, 0x15, 0xed, 0xff, 0xba, 0x00, 0x00, 0x00}, 24 | }, 25 | // ? (LG Resu -> Inverter): unknown message type (appears to be a constant) 26 | { 27 | Identifier: rs.BMS_SERIAL_NUM, 28 | Data: [8]byte{0x04, 0xc0, 0x00, 0x1f, 0x03, 0x00, 0x00, 0x00}, 29 | }, 30 | // configuration parameters (LG Resu -> Inverter): 31 | { 32 | Identifier: rs.BMS_LIMITS, 33 | Data: [8]byte{0x41, 0x02, 0x96, 0x03, 0x96, 0x03, 0x00, 0x00}, 34 | }, 35 | // state of charge/health (LG Resu -> Inverter): 36 | { 37 | Identifier: rs.BMS_SOC_SOH, 38 | Data: [8]byte{0x4d, 0x00, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00}, 39 | }, 40 | // warnings/alarms (LG Resu -> Inverter): 41 | { 42 | Identifier: rs.BMS_WARN_ALARM, 43 | Data: [8]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 44 | }, 45 | } 46 | 47 | // default value is the virtual CANBus interface: vcan0 48 | var i = flag.String("if", "vcan0", "network interface name") 49 | 50 | func main() { 51 | 52 | fmt.Printf("lgresu_sim:\n") 53 | 54 | flag.Parse() 55 | if len(*i) == 0 { 56 | flag.Usage() 57 | os.Exit(1) 58 | } 59 | 60 | iface, err := net.InterfaceByName(*i) 61 | 62 | if err != nil { 63 | log.Fatalf("lgresu_sim: Could not find network interface %s (%v)", *i, err) 64 | } 65 | 66 | // bind to socket 67 | conn, err := can.NewReadWriteCloserForInterface(iface) 68 | 69 | if err != nil { 70 | log.Fatal(err) 71 | } 72 | 73 | bus := can.NewBus(conn) 74 | 75 | c := make(chan os.Signal) 76 | signal.Notify(c, os.Interrupt) 77 | signal.Notify(c, os.Kill) 78 | 79 | go func() { 80 | select { 81 | case <-c: 82 | bus.Disconnect() 83 | os.Exit(1) 84 | } 85 | }() 86 | 87 | f := can.Frame{} 88 | 89 | for { 90 | // send all LG Resu 10 test messages in one block 91 | for _, tm := range canbusTestMessages { 92 | 93 | f.ID = tm.Identifier 94 | f.Length = uint8(len(tm.Data)) 95 | f.Data = tm.Data 96 | 97 | bus.Publish(f) 98 | 99 | fmt.Printf("%#4x # % -24X \n", tm.Identifier, tm.Data) 100 | } 101 | 102 | // wait for 1 second 103 | <-time.After(time.Second * 1) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /datarecorder/datarecorder.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Jens Kaemmerer. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package datarecorder contains functions to write metrics to a datafile and 16 | // to manage the collection of datafiles. 17 | // 18 | // Datafiles can be of any format (JSON, CSV, ...). Filenames and directory 19 | // name reflect the date when the file was created and have the format: 20 | // 21 | // /YYYY/MM/YYYYMMDD 22 | // 23 | // RootPath and Extension are define in the constructor for datafile. 24 | // The first line of the datafiles contains a header describing the data 25 | // columns. 26 | // 27 | // Files older than RetentionPeriod days are automatically deleted to 28 | // maintain a constant number of files. 29 | // 30 | // Example: 31 | // 32 | // Metrics from 01/08/2006 would have been written to a CSV file 33 | // with the filename: 20060108.csv 34 | // 35 | // time,soc,soh,current,voltage,temp 36 | // 2006/01/02 15:04:05,97,99,-3.2,57.43,21.3 37 | // 38 | // Directory hierarchy with RetentionPeriod set to 7 days: 39 | // 40 | // data 41 | // └── 2006 42 | // └── 01 43 | // ├── 20060102.csv 44 | // ├── 20060103.csv 45 | // ├── 20060104.csv 46 | // ├── 20060105.csv 47 | // ├── 20060106.csv 48 | // ├── 20060107.csv 49 | // └── 20060108.csv 50 | // 51 | // 52 | package datarecorder 53 | 54 | import ( 55 | "fmt" 56 | log "github.com/sirupsen/logrus" 57 | "os" 58 | "path/filepath" 59 | "strconv" 60 | "strings" 61 | "time" 62 | ) 63 | 64 | // Github triggers update of godoc documentation. 65 | type Github int 66 | 67 | // Datarecorder contains configuration parameters for datafile management (RootPath, Extension, RetentionPeriod) 68 | // and state information (FileName, FileDesc). 69 | type Datarecorder struct { 70 | RootPath string 71 | Extension string 72 | RetentionPeriod int 73 | Header string 74 | FileName string 75 | FileDesc *os.File 76 | } 77 | 78 | func check(e error) { 79 | if e != nil { 80 | log.Fatal(e) 81 | } 82 | } 83 | 84 | // exists reports whether the named file or directory exists. 85 | func exists(name string) bool { 86 | if _, err := os.Stat(name); err != nil { 87 | if os.IsNotExist(err) { 88 | return false 89 | } 90 | } 91 | return true 92 | } 93 | 94 | // deleteExpiredFiles removes all files that are older than the retentionPeriod days. 95 | func deleteExpiredFiles(currentTime time.Time, rootDir string, retentionPeriod int) ([]string, error) { 96 | 97 | fileList := make([]string, 0) 98 | 99 | // define datafile cutoff date 100 | 101 | // subtract retentionPeriod days from currentTime 102 | cutoff := currentTime.AddDate(0, 0, -(retentionPeriod - 1)) 103 | 104 | log.Debugf("deleteExpiredFiles: cutoff = %v, currentTime = %v \n", cutoff, currentTime) 105 | 106 | err := filepath.Walk(rootDir, func(path string, f os.FileInfo, err error) error { 107 | if !f.IsDir() { 108 | // find files that are older than retentionPeriod days 109 | // decode filename and compare with current timestamp 110 | 111 | // split filename into basename and extension 112 | log.Debugf("deleteExpiredFiles: datafile %s \n", f.Name()) 113 | 114 | str := strings.Split(f.Name(), ".") 115 | 116 | // basename without extension 117 | basename := str[0] 118 | 119 | // parse date string 120 | layout := "20060102" 121 | datafileTime, err := time.Parse(layout, basename) 122 | check(err) 123 | 124 | if cutoff.After(datafileTime) { 125 | // add file to list of files to be deleted 126 | log.Debugf("deleteExpiredFiles: to be deleted datafile %s \n", f.Name()) 127 | fileList = append(fileList, path) 128 | } 129 | } 130 | return err 131 | }) 132 | check(err) 133 | 134 | for _, file := range fileList { 135 | 136 | log.Infof("deleteExpiredFiles: finally deleting datafile %s \n", file) 137 | // delete file 138 | err := os.Remove(file) 139 | check(err) 140 | } 141 | 142 | return fileList, nil 143 | } 144 | 145 | // NewDatarecorder is the constructor for Datarecorder. rootPath is the absolute path intended as the 146 | // starting point of the datarecorder maintained directory hierarchy, extension is the individual datafile 147 | // extension (example: .csv, .json, etc.), retentionPeriod is the time in day until an individual datafile 148 | // is expired, header is a string containing a description of the datafile file columns. 149 | func NewDatarecorder(rootPath string, extension string, retentionPeriod int, header string) *Datarecorder { 150 | dr := &Datarecorder{rootPath, extension, retentionPeriod, header, "", nil} 151 | return dr 152 | } 153 | 154 | // WriteToDatafile writes a new record to a CSV datafile. currentTime represents the time 155 | // of the metric measurement contained in record. record is a string containing the metric measurement 156 | // (in the case of a CSV record a single line with comma separated values). 157 | func (dr *Datarecorder) WriteToDatafile(currentTime time.Time, record string) { 158 | 159 | var err error 160 | 161 | // extract year, month, day strings from date 162 | year := strconv.Itoa(currentTime.Year()) 163 | month := fmt.Sprintf("%02d", currentTime.Month()) // padding with 0's 164 | day := fmt.Sprintf("%02d", currentTime.Day()) // padding with 0's 165 | 166 | // construct filename 167 | fileName := year + month + day + dr.Extension 168 | 169 | // test if file already exists 170 | if fileName != dr.FileName { 171 | // platform independent path name concatenation 172 | folderPath := filepath.Join(dr.RootPath, year, month) 173 | 174 | // create new directory if it does not already exist 175 | os.MkdirAll(folderPath, 0755) 176 | 177 | // construct absolute file path 178 | filePath := filepath.Join(folderPath, fileName) 179 | log.Debugf("write to new datafile at: %s \n", filePath) 180 | 181 | // check if file has previously been created 182 | fileExists := exists(filePath) 183 | 184 | // update Datarecorder struct 185 | dr.FileName = fileName 186 | 187 | // close previous file descriptor 188 | if dr.FileDesc != nil { 189 | dr.FileDesc.Close() 190 | } 191 | 192 | // create new file or append to existing file 193 | dr.FileDesc, err = os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 194 | check(err) 195 | 196 | // write header information only if the file has been newly created 197 | if !fileExists { 198 | _, err = dr.FileDesc.WriteString(dr.Header) 199 | check(err) 200 | } 201 | 202 | // check if aged out files need to be deleted 203 | _, err = deleteExpiredFiles(currentTime, dr.RootPath, dr.RetentionPeriod) 204 | check(err) 205 | } 206 | 207 | _, err = dr.FileDesc.WriteString(record) 208 | check(err) 209 | } 210 | -------------------------------------------------------------------------------- /datarecorder/datarecorder_test.go: -------------------------------------------------------------------------------- 1 | package datarecorder 2 | 3 | import ( 4 | "bufio" 5 | log "github.com/sirupsen/logrus" 6 | "os" 7 | "path/filepath" 8 | "strconv" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | const ( 14 | extension string = ".csv" 15 | retentionPeriod int = 7 16 | header string = "time,soc,soh,current,voltage,temp\n" 17 | sampleRecord string = "2006/01/02 15:04:05,97,99,-3.2,57.43,21.3\n" 18 | sampleYear string = "2006" 19 | sampleMonth string = "01" 20 | sampleFileBasename string = "20060102" 21 | ) 22 | 23 | var ( 24 | rootDir string 25 | absFileName string 26 | err error 27 | recordTime time.Time 28 | ) 29 | 30 | func countLines(fileName string) int { 31 | file, _ := os.Open(fileName) 32 | fileScanner := bufio.NewScanner(file) 33 | lineCount := 0 34 | for fileScanner.Scan() { 35 | lineCount++ 36 | } 37 | return lineCount 38 | } 39 | 40 | func countFiles(rootDir string) (int, error) { 41 | fileList := make([]string, 0) 42 | 43 | err := filepath.Walk(rootDir, func(path string, f os.FileInfo, err error) error { 44 | if !f.IsDir() { 45 | fileList = append(fileList, path) 46 | } 47 | return err 48 | }) 49 | 50 | return len(fileList), err 51 | } 52 | 53 | func init() { 54 | // only log warning severity or above. 55 | log.SetLevel(log.WarnLevel) 56 | //log.SetLevel(log.DebugLevel) 57 | // get current working directory 58 | workingDir, err := os.Getwd() 59 | check(err) 60 | 61 | rootDir = filepath.Join(workingDir, "data") 62 | 63 | absFileName = filepath.Join(rootDir, sampleYear, sampleMonth, sampleFileBasename+extension) 64 | 65 | // convert layout timestamp to time object 66 | layout := "20060102" 67 | recordTime, _ = time.Parse(layout, sampleFileBasename) 68 | } 69 | 70 | // TestDatarecorderWriteToDatafileSingleRecord writes a single records. 71 | func TestDatarecorderWriteToDatafileSingleRecord(t *testing.T) { 72 | 73 | df := NewDatarecorder(rootDir, extension, retentionPeriod, header) 74 | 75 | // establish that there is no datafile 76 | _ = os.Remove(absFileName) 77 | 78 | // datafile does exist yet 79 | if exists(absFileName) { 80 | t.Errorf("expect to find no datafile at %s\n", absFileName) 81 | } 82 | 83 | df.WriteToDatafile(recordTime, sampleRecord) 84 | 85 | // check if datafile has been created 86 | if !exists(absFileName) { 87 | t.Errorf("expect to find new datafile at %s\n", absFileName) 88 | } 89 | 90 | // cleanup: remove single datafile 91 | err := os.Remove(absFileName) 92 | check(err) 93 | } 94 | 95 | // TestDatarecorderWriteToDatafileSingleRecord writes multiple records. 96 | func TestDatarecorderWriteToDatafileMultipleRecord(t *testing.T) { 97 | 98 | maxRecords := 20 99 | 100 | df := NewDatarecorder(rootDir, extension, retentionPeriod, header) 101 | 102 | for i := 0; i < maxRecords; i++ { 103 | df.WriteToDatafile(recordTime, sampleRecord) 104 | } 105 | 106 | lines := countLines(absFileName) 107 | 108 | if lines != maxRecords+1 { 109 | t.Errorf("expect to find %d lines in datafile (1*header, 2*datarecords), found %d lines\n", 110 | maxRecords, lines) 111 | } 112 | 113 | // cleanup: remove single datafile 114 | err := os.Remove(absFileName) 115 | check(err) 116 | } 117 | 118 | // TestDatarecorderWriteToDatafileForRangeOfDays creates a range of datafiles 119 | // and verifies that the total number of datafiles never exceeds retentionPeriodtests. 120 | func TestDatarecorderWriteToDatafileForRangeOfDays(t *testing.T) { 121 | 122 | df := NewDatarecorder(rootDir, extension, retentionPeriod, header) 123 | 124 | // test start time: 12/20/2005 125 | layout := "20060102" 126 | startTime, _ := time.Parse(layout, "20051220") 127 | 128 | // create 20 datafiles for 12/20/2005 to 01/08/2006 129 | for i := 0; i < 20; i++ { 130 | recordTime := startTime.AddDate(0, 0, i) 131 | year := strconv.Itoa(recordTime.Year()) 132 | 133 | // write a record 134 | df.WriteToDatafile(recordTime, sampleRecord) 135 | // verify that the total number of datafiles never exceeds retentionPeriod 136 | fileCnt, _ := countFiles(filepath.Join(rootDir, year)) 137 | log.Debugf("TestDatarecorderWriteToDatafileForRangeOfDays: fileCnt = %d \n", fileCnt) 138 | 139 | if fileCnt > retentionPeriod { 140 | t.Errorf("expect to find %d datafiles or less, found %d datafiles\n", 141 | retentionPeriod, fileCnt) 142 | } 143 | } 144 | 145 | // cleanup: remove entire directory hierarchy 146 | err = os.RemoveAll(rootDir) 147 | check(err) 148 | 149 | } 150 | -------------------------------------------------------------------------------- /doc/LGResuMon.adoc: -------------------------------------------------------------------------------- 1 | 2 | = LG Resu CANBus Monitoring System 3 | Jens Kaemmerer 4 | v1.1, 04-19-2018 5 | :toc: 6 | :toclevels: 4 7 | :sectnums: 8 | 9 | == Hardware configuration 10 | 11 | === Hardware components 12 | 13 | The 4 main components of the system are: 14 | 15 | ==== Raspberry PI 1 model B: 16 | 17 | https://en.wikipedia.org/wiki/Raspberry_Pi + 18 | 19 | ==== CANBus module: 20 | 21 | http://ww1.microchip.com/downloads/en/DeviceDoc/21801e.pdf + 22 | https://www.nxp.com/docs/en/data-sheet/TJA1050.pdf 23 | 24 | Ebay: http://r.ebay.com/DBujCT 25 | 26 | NOTE: This module is intended to be used with an Arduino (GPIO Voltage range: 0-5 VDC) and has to be modified 27 | according to these instructions to work with a Raspberry (GPIO Voltage range: 0-3.3 VDC): 28 | 29 | https://www.raspberrypi.org/forums/viewtopic.php?t=141052 30 | 31 | The MCP2515 chip can be operated within a voltage range of 2.7-5.5 VDC. 32 | 33 | ==== DC-DC buck converter: 34 | 35 | http://www.ti.com/lit/ds/symlink/lm2596.pdf + 36 | 37 | Ebay: http://r.ebay.com/OGuYa1 38 | 39 | Output voltage is set to 5VDC. Input voltage can be as high as 40VDC. 40 | 41 | In order connect a DC-DC buck converter directly to the 48VDC LGResu 10 LV battery, the input voltage range has to be as high as 60VDC: 42 | 43 | Amazon: http://a.co/gFgr6Gd 44 | 45 | ==== RJ45 breakout board: 46 | 47 | Dual RJ45 Ethernet Connector Breakout Board with screw terminals: 48 | 49 | EBay: http://r.ebay.com/MkquWx 50 | 51 | image::lg_resu_mon_hardware_1200x800.jpg[] 52 | 53 | === Power 54 | 55 | The system can be powered with either 5VDC (micro USB plug) or 56 | with 12VDC (DC connector: 2.1mm inner diameter, 5.5mm outer diameter). 57 | The powersupply should be able to output 5W continuous. 58 | 59 | The Raspberry PI 1 power consumption is less than 3W, the monitoring 60 | software consumes very little CPU time. 61 | 62 | === Network 63 | 64 | The Raspberry PI 1 has a build in 100 MBit Ethernet adapter. A USB Wifi adapter can 65 | be inserted into a USB port. 66 | 67 | === Canbus 68 | 69 | ==== Connect CANBus Monitoring System at the end of CANBus cable 70 | 71 | A CANBus network needs a 120 Ohm termination resistor at each end of the network. 72 | The LG Resu 10 LV already has one of the termination resistors. The second termination resistor 73 | needs to be enabled with the J1 jumper on the CANBus module (see picture in section: Hardware 74 | components). 75 | 76 | The CANBus cable can be inserted into either of the 2 RJ45 ports. 77 | 78 | CANBUS network nodes: 79 | 80 | ---- 81 | LG Resu Monitoring system (120 Ohm R) <-> LG Resu 10 LV battery (120 Ohm R) 82 | ---- 83 | 84 | ==== Connect CANBus Monitoring System in between existing CANBus nodes 85 | 86 | Addition of the monitoring system at any point between 2 existing CANBus nodes requires that the 87 | termination resistor on the CANBus module is disabled (no jumper on J1). 88 | 89 | Two CANBus cables needs to be inserted into the 2 RJ45 ports. 90 | 91 | CANBUS network nodes (example): 92 | 93 | ---- 94 | Conext Bridge (120 Ohm R) <-> LG Resu Monitoring system <-> LG Resu 10 LV battery (120 Ohm R) 95 | ---- 96 | 97 | == Software configuration 98 | 99 | === Software components 100 | 101 | SocketCAN CANBus driver: 102 | 103 | Raspbian Stretch Lite (Linux kernel 4.9): https://www.raspberrypi.org/ + 104 | SocketCAN (Linux kernel 4.9): https://www.kernel.org/doc/Documentation/networking/can.txt 105 | 106 | CANBus command line utilities: 107 | 108 | can-utils (0.0+git20161220-1): https://github.com/linux-can/can-utils 109 | 110 | LG Resu Monitoring application: 111 | 112 | lgresu (1.0): https://github.com/jens18/lgresu 113 | 114 | === CANBus 115 | 116 | ==== Automated configuration 117 | 118 | Configuration of the CANBus interface on the Raspberry PI has been automated in: 119 | 120 | `/etc/rc.local` 121 | 122 | ---- 123 | # configure CANBus interface 124 | /sbin/ip link set can0 type can bitrate 500000 restart-ms 100 125 | /sbin/ifconfig can0 up 126 | /sbin/ifconfig can0 127 | /usr/bin/candump -n 5 can0 128 | ---- 129 | 130 | ==== Manual configuration 131 | 132 | The required speed for a CANBus node communicating with the LG Resu 10 LV is 500 kBit/s. 133 | 134 | CANBus speed needs to be specificed when configuring the Linux SocketCAN interface: 135 | 136 | ---- 137 | # /sbin/ip link set can0 type can bitrate 500000 restart-ms 100 138 | ---- 139 | 140 | The interface can be started with: 141 | 142 | ---- 143 | # /sbin/ifconfig can0 up 144 | ---- 145 | 146 | and stopped with: 147 | 148 | ---- 149 | # /sbin/ifconfig can0 down 150 | ---- 151 | 152 | Display interface details: 153 | 154 | ---- 155 | $ ifconfig can0 156 | ifconfig can0 157 | can0: flags=193 mtu 16 158 | unspec 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00 txqueuelen 10 (UNSPEC) 159 | RX packets 868643 bytes 6949144 (6.6 MiB) 160 | RX errors 0 dropped 97 overruns 0 frame 0 161 | TX packets 8502 bytes 68016 (66.4 KiB) 162 | TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 163 | ---- 164 | 165 | NOTE: It is normal to see `dropped` packets (in the example: 97). This number will increase 166 | until a CANBus application (for example: `candump`) connects to the interface for the first time. 167 | 168 | === DHCP 169 | 170 | DHCP is enabled. 171 | 172 | A _static lease_ can be configured in the router for the MAC address contained in the output of 173 | the `ifconfig` command: 174 | 175 | ---- 176 | # ifconfig eth0 177 | eth0: flags=4163 mtu 1500 178 | inet 192.168.29.34 netmask 255.255.255.0 broadcast 192.168.29.255 179 | inet6 fe80::10ad:7c00:43c6:c9ef prefixlen 64 scopeid 0x20 180 | ether b8:27:eb:d9:82:b1 txqueuelen 1000 (Ethernet) 181 | RX packets 2451 bytes 131185 (128.1 KiB) 182 | RX errors 0 dropped 2 overruns 0 frame 0 183 | TX packets 432 bytes 74969 (73.2 KiB) 184 | TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 185 | ---- 186 | 187 | The example MAC address is: 188 | 189 | ---- 190 | b8:27:eb:d9:82:b1 191 | ---- 192 | 193 | === SSH 194 | 195 | Logging into the LG Resu Monitor system is possible using any SSH client: 196 | 197 | ---- 198 | $ ssh -l pi 192.168.X.Y 199 | ---- 200 | 201 | login: pi + 202 | password: raspberry 203 | 204 | NOTE: `raspberry` is the default `pi` user password for Rasbian and should be changed. 205 | 206 | === sudo 207 | 208 | Login as the super user `root` is only possible via `sudo`: 209 | 210 | ---- 211 | $ sudo bash 212 | # 213 | ---- 214 | 215 | `sudo` is enabled for the regular user `pi`. 216 | 217 | === HDMI 218 | 219 | HDMI can be permantently disabled to reduce power consumption by removing the # character in front of the 220 | `tvservice` command in `/etc/rc.local`: 221 | 222 | ---- 223 | # turn HDMI circuit off 224 | # /usr/bin/tvservice -o 225 | ---- 226 | 227 | WARNING: With HDMI disabled, it will not be possible to connect the Raspberry PI to a monitor / keyboard 228 | in the event a network connection can not be established. 229 | 230 | HDMI can be re-enable with the command: 231 | 232 | ---- 233 | $ /usr/bin/tvservice -p 234 | ---- 235 | 236 | === logrotate 237 | 238 | Logfile rotation for the logfiles generated by the LG Resu CANBus Monitoring System has been configured in: 239 | 240 | ---- 241 | # more /etc/logrotate.d/lgresu 242 | /opt/lgresu/log/*.log { 243 | missingok 244 | notifempty 245 | compress 246 | size 20k 247 | daily 248 | copytruncate 249 | } 250 | ---- 251 | 252 | === lgresu 253 | 254 | ==== Package directory structure 255 | 256 | The currently used `lgresu` software package is installed in the directory: 257 | 258 | `/opt/lgresu` 259 | 260 | The `lgresu' software package contains the following files: 261 | 262 | ---- 263 | lgresu 264 | ├── bin 265 | │   └── lg_resu_mon 266 | ├── doc 267 | │   └── LgResuMon.pdf 268 | ├── script 269 | │   ├── can_stats.sh 270 | │   ├── keep_alive.sh 271 | │   ├── lg_resu_dashboard.json 272 | │   └── start_interface.sh 273 | └── start_lg_resu_mon.sh 274 | ---- 275 | 276 | The startup of the `lg_resu_mon` server program with the script `start_lg_resu_mon.sh` is integrated with the 277 | Rasbian operating system startup in: 278 | 279 | `/etc/rc.local` 280 | 281 | ---- 282 | # lg_resu_mon 283 | /opt/lgresu/start_lg_resu_mon.sh 284 | ---- 285 | 286 | The manual startup command is: 287 | 288 | ---- 289 | # /opt/lgresu/start_lg_resu_mon.sh 290 | ---- 291 | 292 | Verify that the `lg_resu_mon` process has been started: 293 | 294 | ---- 295 | # pgrep -a lg_resu_mon 296 | 2087 ./bin/lg_resu_mon -if can0 297 | ---- 298 | 299 | ==== Package installation 300 | 301 | The `lgresu` software package file name is: `lgresu-1.2-linux-armv7l.tar.gz` 302 | 303 | NOTE: This package has been build on an `armv7l` system (Raspberry PI 3) but can also be used on an `armv6l` system (Raspberry PI 1). 304 | 305 | Stop the existing `lg_resu_mon` process instance and verify that the process has been stopped: 306 | 307 | ---- 308 | # pkill lg_resu_mon 309 | # ps -ef |grep lg_resu_mon 310 | ---- 311 | 312 | Extract the `lgresu` software package with the commands: 313 | 314 | ---- 315 | # cd /opt 316 | # tar xvfz /home/pi/lgresu-1.2-linux-armv7l.tar.gz 317 | ---- 318 | 319 | This will create a new directory: `/opt/lgresu-1.2` 320 | 321 | Remove the existing `lgresu` symbolic link: 322 | 323 | ---- 324 | # rm lgresu 325 | ---- 326 | 327 | Create a a new symbolic link to the `lgresu` software version you would like to use: 328 | 329 | ---- 330 | # ln -s lgresu-1.2 lgresu 331 | # ls -l 332 | total 12 333 | lrwxrwxrwx 1 root root 10 Apr 19 11:52 lgresu -> lgresu-1.2 334 | drwxr-xr-x 6 pi pi 4096 Apr 19 11:52 lgresu-1.2 335 | ---- 336 | 337 | ==== Server: Command line parameters 338 | 339 | The `lg_resu_mon` server support the following commandline parameters: 340 | 341 | ---- 342 | # ./lg_resu_mon --help 343 | 344 | Usage of ./lgresu_mon: 345 | -d string 346 | log level: debug, info, warn, error (default "info") 347 | -dr string 348 | root directory for metric datafiles (default "/opt/lgresu") 349 | -if string 350 | network interface name (default "vcan0") 351 | -p string 352 | port number (default "9090") 353 | -r int 354 | metric datafile retention period in days (default 7) 355 | ---- 356 | 357 | Changes to the default parameters can be persisted by updating the script `start_lg_resu_mon.sh`. 358 | 359 | ==== UI: node-RED flow import 360 | 361 | The `lg_resu_mon` UI requires a http://node-red.org[node-RED] environment. node-RED can be 362 | installed on the Raspberry PI or on any other machine in the network. 363 | 364 | The `/opt/lgresu/script/lg_resu_dashboard.json` node-RED flow implements the LG Resu Monitoring 365 | dashboard web application. 366 | 367 | ===== Install node-RED dependencies 368 | 369 | The `lg_resu_dashboard` flow depends on the additional node-RED node: `node-red-dashboard` 370 | 371 | `node-red-dashboard` can easily be added to the `pallete` of node-RED nodes. 372 | 373 | Start by connecting to your node-RED instance: 374 | 375 | http://:1880 376 | 377 | ---- 378 | Menu -> Manage Palette -> tab: Install -> search: node-red-dashboard 379 | ---- 380 | 381 | image::node_red_manage_palette.png[] 382 | 383 | Click the small `install` button on the right side of the `node-red-dashboard` entry (if it is not already installed). 384 | 385 | image::node_red_dashboard_install.png[] 386 | 387 | Restart the node-RED environment: 388 | 389 | ---- 390 | $ node-red-stop 391 | $ node-red-start 392 | ---- 393 | 394 | ===== Import LG Resu Monitoring node-RED flow 395 | 396 | Cut and Paste the entire Json file: `/opt/lgresu/script/lg_resu_dashboard.json` 397 | 398 | ---- 399 | Menu -> Import -> Clipboard 400 | ---- 401 | 402 | Click `Import` button. You should now see the following flow: 403 | 404 | image::node_red_import.png[] 405 | 406 | Doubleclick the HTTP request node to update the current IP address with the IP address of the 407 | machine running the `lg_resu_mon` server: 408 | 409 | image::node_red_edit_ip_addr.png[] 410 | 411 | Deploy the customized flow with the `Deploy` button in the upper right corner. 412 | 413 | You can now test the flow by clicking on the pad to the left of the `timestamp` inject node. This will trigger 414 | a HTTP request to the `lg_resu_mon` server. You should see the result of this request in the `debug` 415 | tab on the right side of the node-RED screen. 416 | 417 | == Monitoring 418 | 419 | === HTTP: Monitoring Dashboard UI 420 | 421 | The LG Resu Monitoring dashboard can be accessed at: 422 | 423 | http://:1880/ui 424 | 425 | image::lg_resu_dashboard_phone.png[Screenshot,375,660] 426 | 427 | === HTTP: Json message 428 | 429 | `lg_resu_mon` listens to HTTP REST requests on port 9090: 430 | 431 | http://:9090 432 | 433 | and responds with a JSON message containing the LG Resu metrics. 434 | 435 | Wget: 436 | 437 | ---- 438 | $ wget http://192.168.29.30:9090 439 | --2018-04-19 14:06:42-- http://192.168.29.30:9090/ 440 | Connecting to 192.168.29.30:9090... connected. 441 | HTTP request sent, awaiting response... 200 OK 442 | Length: 159 [application/json] 443 | Saving to: ‘index.html’ 444 | 445 | index.html 100%[================================>] 159 --.-KB/s in 0s 446 | 447 | 2018-04-19 14:06:43 (1.90 MB/s) - ‘index.html’ saved [159/159] 448 | 449 | $ more index.html 450 | {"soc":62,"soh":99,"voltage":53.39,"current":6,"temp":19.4,"maxVoltage":57.7,"maxChargeCurrent":93 451 | .6,"maxDischargeCurrent":93.6,"warnings":null,"alarms":null} 452 | ---- 453 | 454 | Firefox: 455 | 456 | image::firefox_json_lgresu.png[] 457 | 458 | === CSV datafiles 459 | 460 | `lg_resu_mon` persists LG Resu metrics in CSV datafiles. Granularity of the CSV datafiles is 1 minute. 461 | 462 | Example CSV datafile: 20180531.csv 463 | 464 | ---- 465 | Time,Soc,Voltage,Current 466 | ... 467 | 2018/05/31 18:01:53,80,54.82,-1.10 468 | 2018/05/31 18:02:53,80,54.83,-0.10 469 | 2018/05/31 18:03:53,80,54.82,-0.50 470 | 2018/05/31 18:04:53,80,54.82,-0.50 471 | ... 472 | ---- 473 | 474 | For every day a new CSV datafile is created. The total number datafiles in the 'data' directory 475 | is limited by the retention period command line parameter (`-r`). 476 | 477 | CSV metric datafiles are organized in a hierarchy of directories starting with the year directory, followed 478 | by the month directory which contains the most recent datafiles for the current month. 479 | 480 | Example directory hierarchy: 481 | 482 | ---- 483 | data 484 | └── 2018 485 | └── 05 486 | └── 20180525.csv 487 | └── 20180526.csv 488 | └── 20180527.csv 489 | └── 20180528.csv 490 | └── 20180529.csv 491 | └── 20180530.csv 492 | └── 20180531.csv 493 | ---- 494 | 495 | === HTTP: CSV datafiles 496 | 497 | CSV datafiles can be directly access with HTTP requests: 498 | 499 | http://:9090/data/2018/05/0180531.csv 500 | 501 | A web browser can be used to interactively explore the directory hierarchy with the HTTP request: 502 | 503 | http://:9090/data/ 504 | 505 | === Log file 506 | 507 | Addition of the option `-d debug` to the `lg_resu_mon` commandline in the script `/opt/lgresu/start_lg_resu_mon.sh` 508 | displays all of the CANBus messages send by the LG Resu 10 LV: 509 | 510 | ---- 511 | # cd /opt/lgresu/log 512 | # tail -11 lg_resu_mon.log 513 | max charge voltage = 57.70 [VDC] 514 | max charge current = 91.30 [ADC] 515 | max discharge current = 91.30 [ADC] 516 | 517 | soc = 78 % 518 | soh = 99 % 519 | 520 | voltage = 54.71 [VDC] 521 | current = 3.10 [ADC] 522 | temperature = 18.9 [Celsius] 523 | ---- 524 | 525 | === Candump 526 | 527 | Display raw CANBus message data from the LG Resu 10 LV with the `candump` command: 528 | 529 | ---- 530 | # /usr/bin/candump -n 5 can0 531 | can0 359 [8] 00 00 00 00 00 00 00 00 532 | can0 351 [8] 41 02 91 03 91 03 00 00 533 | can0 355 [8] 4E 00 63 00 00 00 00 00 534 | can0 356 [8] 60 15 1C 00 BD 00 00 00 535 | can0 354 [8] 04 C0 00 1F 03 00 00 00 536 | ---- 537 | 538 | == Troubleshooting 539 | 540 | === Problem: Node disconnected with the CANBus state `BUS-OFF` (and the flag: `NO-CARRIER`). 541 | 542 | Example: 543 | ---- 544 | $ bash ./can_stats.sh 545 | 3: can0: mtu 16 qdisc pfifo_fast state DOWN mode DEFAULT group default qlen 10 546 | link/can promiscuity 0 547 | can state BUS-OFF restart-ms 0 548 | bitrate 500000 sample-point 0.750 549 | tq 250 prop-seg 2 phase-seg1 3 phase-seg2 2 sjw 1 550 | mcp251x: tseg1 3..16 tseg2 2..8 sjw 1..4 brp 1..64 brp-inc 1 551 | clock 4000000 552 | re-started bus-errors arbit-lost error-warn error-pass bus-off 553 | 0 0 0 2 2 1 numtxqueues 1 gso_max_size 65536 gso_max_segs 65535 554 | RX: bytes packets errors dropped overrun mcast 555 | 355424 44451 0 530 0 0 556 | TX: bytes packets errors dropped carrier collsns 557 | 3440 430 0 0 0 0 558 | ---- 559 | 560 | In this condition, `top` output typically shows that the interrupt handler is consuming a high CPU percentage: 561 | 562 | ---- 563 | $ top 564 | top - 07:39:29 up 9:29, 1 user, load average: 2.98, 2.78, 2.58 565 | Tasks: 89 total, 2 running, 87 sleeping, 0 stopped, 0 zombie 566 | %Cpu(s): 0.0 us, 96.3 sy, 0.0 ni, 3.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st 567 | KiB Mem : 444452 total, 221044 free, 22848 used, 200560 buff/cache 568 | KiB Swap: 102396 total, 102396 free, 0 used. 369788 avail Mem 569 | 570 | PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 571 | 562 root -51 0 0 0 0 R 99.9 0.0 396:21.67 irq/185-mcp251x 572 | 1208 pi 20 0 8096 3204 2720 R 1.5 0.7 0:00.20 top 573 | 1128 root 20 0 0 0 0 S 0.2 0.0 0:00.29 kworker/0:2 574 | 1160 pi 20 0 11636 3900 3136 S 0.2 0.9 0:00.25 sshd 575 | ---- 576 | 577 | Solution: 578 | 579 | Restart the interface with the following commands: 580 | 581 | ---- 582 | # ip link set can0 down 583 | # ip link set can0 up 584 | ---- 585 | 586 | Verify that the interface is now in the state `ERROR-ACTIVE` (normal operation). 587 | 588 | Example: 589 | 590 | ---- 591 | # bash ../script/can_stats.sh 592 | 3: can0: mtu 16 qdisc pfifo_fast state UNKNOWN mode DEFAULT group default qlen 10 593 | link/can promiscuity 0 594 | can state ERROR-ACTIVE restart-ms 100 595 | bitrate 500000 sample-point 0.750 596 | tq 250 prop-seg 2 phase-seg1 3 phase-seg2 2 sjw 1 597 | mcp251x: tseg1 3..16 tseg2 2..8 sjw 1..4 brp 1..64 brp-inc 1 598 | clock 4000000 599 | re-started bus-errors arbit-lost error-warn error-pass bus-off 600 | 0 0 0 0 0 0 numtxqueues 1 gso_max_size 65536 gso_max_segs 65535 601 | RX: bytes packets errors dropped overrun mcast 602 | 45408 5676 0 0 0 0 603 | TX: bytes packets errors dropped carrier collsns 604 | 440 55 0 0 0 0 605 | ---- 606 | 607 | -------------------------------------------------------------------------------- /doc/LGResuMon.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jens18/lgresu/1d330b5add3af7d02393cad9c88c24a4b02882b2/doc/LGResuMon.pdf -------------------------------------------------------------------------------- /doc/RPISetup.adoc: -------------------------------------------------------------------------------- 1 | 2 | == Raspberry PI 1 Setup Instructions 3 | 4 | === Install Raspbian 5 | 6 | Download Raspbian Stretch Lite (server only): 7 | 8 | https://www.raspberrypi.org/downloads/raspbian/ 9 | 10 | Uncompress Raspbian Stretch Lite: 11 | 12 | ---- 13 | # unzip 2018-03-13-raspbian-stretch-lite.zip 14 | # ls -l 2018-03-13* 15 | -rw-r--r-- 1 root root 1858076672 Mar 13 22:53 2018-03-13-raspbian-stretch-lite.img 16 | -rw-rw-r-- 1 jens jens 365765304 Mar 14 10:56 2018-03-13-raspbian-stretch-lite.zip 17 | ---- 18 | 19 | Unmount all existing partition of the SD card: 20 | 21 | ---- 22 | # umount /dev/sdb1 23 | # umount /dev/sdb2 24 | ---- 25 | 26 | Copy Raspbian Stretch Lite to SD card: 27 | 28 | ---- 29 | # dd bs=4M if=2018-03-13-raspbian-stretch-lite.img of=/dev/sdb conv=fsync status=progress 30 | 1853882368 bytes (1.9 GB, 1.7 GiB) copied, 241.004 s, 7.7 MB/s 31 | 443+0 records in 32 | 443+0 records out 33 | 1858076672 bytes (1.9 GB, 1.7 GiB) copied, 301.864 s, 6.2 MB/s 34 | ---- 35 | 36 | Remove and re-insert the SD card to trigger automounter. 37 | 38 | Place empty `ssh` file in root filesystem to automatically launch SSH server on first 39 | boot without having to connect an HDMI monitor and USB keyboard in order to enable SSH: 40 | 41 | ---- 42 | # cd /media/jens/boot 43 | # touch ssh 44 | ---- 45 | 46 | Boot Raspberry PI and login with SSH to enable VNC: 47 | 48 | ---- 49 | # raspi-config 50 | enable VNC: Interfaces -> VNC: enable 51 | ---- 52 | 53 | === Configure Raspbian 54 | 55 | l: pi + 56 | p: raspberry 57 | 58 | Start `raspi-config`: 59 | 60 | ---- 61 | # raspi-config 62 | ---- 63 | 64 | 4 Localization -> Timezone: Pacific New (PDT/PST) + 65 | 5 Interfacing Options -> enable: SPI (required for CANBus module) + 66 | 6 Overclock -> Modest (800 Mhz ARM) + 67 | 68 | === Upgrade Raspbian 69 | 70 | ---- 71 | # apt-get update // update package repository 72 | # apt-get upgrade 73 | ---- 74 | 75 | === Boot configuration 76 | 77 | Edit `/boot/config.txt` and reboot: 78 | 79 | ---- 80 | dtparam=spi=on 81 | dtoverlay=mcp2515-can0,oscillator=8000000,interrupt=25 82 | dtoverlay=spi-bcm2835-overlay 83 | ---- 84 | 85 | After reboot - test presence of SPI and MCP2515: 86 | 87 | ---- 88 | # dmesg | fgrep -i mcp 89 | [ 18.240774] mcp251x spi0.0 can0: MCP2515 successfully initialized. 90 | ---- 91 | 92 | ---- 93 | # dmesg | fgrep -i can 94 | [ 18.183761] CAN device driver interface 95 | [ 18.240774] mcp251x spi0.0 can0: MCP2515 successfully initialized. 96 | [ 503.121288] IPv6: ADDRCONF(NETDEV_CHANGE): can0: link becomes ready 97 | [ 729.404467] can: controller area network core (rev 20120528 abi 9) 98 | [ 729.428158] can: raw protocol (rev 20120528) 99 | ---- 100 | 101 | NOTE: `can0` link information will only appear if the interface has 102 | already been configured. Instructions on how to configure the `can0` 103 | interface can be found below. 104 | 105 | === Configure Powersaving options 106 | 107 | Disable HDMI circuit 108 | 109 | Edit `/etc/rc.local`: 110 | 111 | ---- 112 | /usr/bin/tvservice -o 113 | ---- 114 | 115 | NOTE: HDMI can be re-enable with the command: 116 | 117 | ---- 118 | $ /usr/bin/tvservice -p 119 | ---- 120 | 121 | === Install additional packages 122 | 123 | ---- 124 | # apt-get install emacs-nox 125 | # apt-get install can-utils 126 | ---- 127 | 128 | === Postfix email relay server 129 | 130 | https://www.linode.com/docs/email/postfix/configure-postfix-to-send-mail-using-gmail-and-google-apps-on-debian-or-ubuntu/ 131 | 132 | Additional changes: 133 | 134 | Define the local subnet to allow emails arriving from the Combox - the default setting of 127.0.0.0/8 135 | only allows emails from the server that run postfix. 136 | 137 | Edit `/etc/postfix/main.cf` to include: 138 | 139 | ---- 140 | myhostname = lgresumon.mesgtone.lan 141 | ... 142 | mynetworks = 127.0.0.0/8 192.168.29.0/24 [::ffff:127.0.0.0]/104 [::1]/128 143 | ... 144 | # Enable SASL authentication 145 | smtp_sasl_auth_enable = yes 146 | # Disallow methods that allow anonymous authentication 147 | smtp_sasl_security_options = noanonymous 148 | # Location of sasl_passwd 149 | smtp_sasl_password_maps = hash:/etc/postfix/sasl/sasl_passwd 150 | # Enable STARTTLS encryption 151 | smtp_tls_security_level = encrypt 152 | # Location of CA certificates 153 | smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt 154 | ---- 155 | 156 | == Optional 157 | 158 | === Install golang 159 | 160 | https://golang.org/dl/ 161 | 162 | ---- 163 | # cd /usr/local 164 | # tar -xzf /home/pi/go1.10.linux-armv6l.tar.gz 165 | ---- 166 | 167 | Edit `/etc/profile`: 168 | 169 | ---- 170 | # golang 1.10 171 | export PATH=$PATH:/usr/local/go/bin 172 | ---- 173 | 174 | Test golang installation: 175 | 176 | ---- 177 | $ mkdir ~/Projects/go/src/hello 178 | $ cat < hello1.go 179 | package main 180 | 181 | import "fmt" 182 | 183 | func main() { 184 | fmt.Printf("hello, world\n") 185 | } 186 | EOF 187 | $ go build 188 | $ ./hello 189 | hello, world 190 | ---- 191 | 192 | === Disable Bluetooth 193 | 194 | Disable Bluetooth services: 195 | 196 | ---- 197 | sudo systemctl disable hciuart.service 198 | sudo systemctl disable bluealsa.service 199 | sudo systemctl disable bluetooth.service 200 | ---- 201 | 202 | https://scribles.net/disabling-bluetooth-on-raspberry-pi/ 203 | 204 | === Wifi 205 | 206 | ---- 207 | $ /sbin/iw dev 208 | phy#0 209 | Interface wlan0 210 | ifindex 3 211 | wdev 0x1 212 | addr 00:13:ef:80:09:77 213 | type managed 214 | txpower 12.00 dBm 215 | 216 | $ sudo ip link show wlan0 217 | 4: wlan0: mtu 1500 qdisc mq state DOWN mode DEFAULT group default qlen 1000 218 | link/ether 00:13:ef:80:09:77 brd ff:ff:ff:ff:ff:ff 219 | 220 | $ iwconfig 221 | lo no wireless extensions. 222 | 223 | eth0 no wireless extensions. 224 | 225 | can0 no wireless extensions. 226 | 227 | wlan0 IEEE 802.11bgn ESSID:"mtv" Nickname:"" 228 | Mode:Managed Frequency:2.437 GHz Access Point: 2C:56:DC:84:D3:AA 229 | Bit Rate:72.2 Mb/s Sensitivity:0/0 230 | Retry:off RTS thr:off Fragment thr:off 231 | Encryption key:****-****-****-****-****-****-****-**** Security mode:open 232 | Power Management:off 233 | Link Quality=99/100 Signal level=60/100 Noise level=0/100 234 | Rx invalid nwid:0 Rx invalid crypt:0 Rx invalid frag:0 235 | Tx excessive retries:0 Invalid misc:0 Missed beacon:0 236 | 237 | # wpa_passphrase mtv > /etc/wpa_supplicant.conf 238 | # cat /etc/wpa_supplicant.conf 239 | # reading passphrase from stdin 240 | network={ 241 | ssid="mtv" 242 | psk=fcdf6d5013cd55eeb8376b0f4ae664efc584737432b33814e888d5194aa3adc8 243 | } 244 | 245 | 246 | # wpa_supplicant -B -D none -i wlan0 -c /etc/wpa_supplicant.conf 247 | Successfully initialized wpa_supplicant 248 | 249 | $ ip route show 250 | sudo ip route del default 251 | sudo ip route add default via 192.168.29.1 dev wlan0 252 | ---- 253 | 254 | Reference: 255 | 256 | https://linuxcommando.blogspot.com/2013/10/how-to-connect-to-wpawpa2-wifi-network.html 257 | 258 | === Node-RED: 259 | 260 | node-RED is a graphical event wiring tool (rapid prototyping environment) from IBM: http://nodered.org 261 | 262 | The Node-RED version that comes pre-installed with the Raspbian desktop version is outdated and needs 263 | to be updated with the following command (the Raspbian server version does not come with Node-RED pre-installed): 264 | 265 | ---- 266 | bash <(curl -sL https://raw.githubusercontent.com/node-red/raspbian-deb-package/master/resources/update-nodejs-and-nodered) 267 | ---- 268 | 269 | Additional Raspberry PI specific instructions: 270 | 271 | https://nodered.org/docs/hardware/raspberrypi 272 | 273 | ==== node-red-contrib-modbus installation 274 | 275 | Instructions from the Youbube video: Raspberry PI Node-RED Tutorial with Modbus & MQTT 276 | 277 | https://www.youtube.com/watch?v=fV78MQks6BI 278 | 279 | ---- 280 | $ cd .node-red 281 | ls 282 | flows_salinas_cred.json flows_salinas.json lib node_modules package.json package-lock.json settings.js 283 | $ npm install node-red-contrib-modbus 284 | npm WARN node-red-project@0.0.1 No repository field. 285 | npm WARN node-red-project@0.0.1 No license field. 286 | 287 | + node-red-contrib-modbus@3.4.0 288 | updated 1 package in 237.55s 289 | $ node-red-stop 290 | $ node-red-start 291 | ---- 292 | 293 | ==== Enable automated startup 294 | 295 | ---- 296 | sudo systemctl enable nodered.service 297 | ---- 298 | 299 | === CANBus test environment: 300 | 301 | Setup the virtual CANBus interface: 302 | 303 | ---- 304 | # modprobe vcan 305 | # ip link add dev vcan0 type vcan 306 | # ip link set up vcan0 307 | # ifconfig vcan0 308 | vcan0 Link encap:UNSPEC HWaddr 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00 309 | UP RUNNING NOARP MTU:16 Metric:1 310 | RX packets:0 errors:0 dropped:0 overruns:0 frame:0 311 | TX packets:0 errors:0 dropped:0 overruns:0 carrier:0 312 | collisions:0 txqueuelen:1 313 | RX bytes:0 (0.0 B) TX bytes:0 (0.0 B) 314 | ---- 315 | 316 | Delete the virtual CANBus interface: 317 | 318 | ---- 319 | # ip link delete vcan0 320 | ---- 321 | 322 | Install `can-utils` package (https://github.com/linux-can/can-utils.git): 323 | 324 | ---- 325 | # sudo apt-get install can-utils 326 | ---- 327 | 328 | Start `candump` and `cansend` in 2 different terminals: 329 | 330 | ---- 331 | terminal 1 $ candump vcan0 332 | 333 | vcan0 001 [8] 11 22 33 44 55 66 77 88 334 | vcan0 001 [8] 11 22 33 44 55 66 77 89 335 | 336 | terminal 2 $ cansend vcan0 001#1122334455667788 337 | terminal 2 $ cansend vcan0 001#1122334455667789 338 | ---- 339 | 340 | Filter the 'keep alive' message with `candump`: 341 | 342 | ---- 343 | $ candump vcan0,305:1ff 344 | vcan0 305 [8] 00 00 00 00 00 00 00 00 345 | ---- 346 | 347 | === Automate configuration of CANBus interface 348 | 349 | `/etc/network/interfaces` 350 | 351 | ---- 352 | auto can0 353 | iface can0 can static 354 | bitrate 500000 355 | ---- 356 | 357 | 358 | Reference: 359 | 360 | 1) CANBus wiring instructions: 361 | 362 | https://www.orionbms.com/general/diagnosing-canbus-communication-problems/ 363 | 364 | http://tekeye.uk/automotive/can-bus-cable-wiring 365 | 366 | http://copperhilltech.com/content/CAN-Bus.pdf 367 | 368 | http://copperhilltech.com/content/CAN-Troubleshooting-Guide.pdf 369 | 370 | 2) Testing of the CANBus physical layer: 371 | 372 | http://copperhilltech.com/content/CIA_article.pdf 373 | -------------------------------------------------------------------------------- /doc/firefox_json_lgresu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jens18/lgresu/1d330b5add3af7d02393cad9c88c24a4b02882b2/doc/firefox_json_lgresu.png -------------------------------------------------------------------------------- /doc/lg_resu_dashboard_phone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jens18/lgresu/1d330b5add3af7d02393cad9c88c24a4b02882b2/doc/lg_resu_dashboard_phone.png -------------------------------------------------------------------------------- /doc/lg_resu_mon_hardware_1200x800.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jens18/lgresu/1d330b5add3af7d02393cad9c88c24a4b02882b2/doc/lg_resu_mon_hardware_1200x800.jpg -------------------------------------------------------------------------------- /doc/node_red_dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jens18/lgresu/1d330b5add3af7d02393cad9c88c24a4b02882b2/doc/node_red_dashboard.png -------------------------------------------------------------------------------- /doc/node_red_dashboard_install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jens18/lgresu/1d330b5add3af7d02393cad9c88c24a4b02882b2/doc/node_red_dashboard_install.png -------------------------------------------------------------------------------- /doc/node_red_edit_ip_addr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jens18/lgresu/1d330b5add3af7d02393cad9c88c24a4b02882b2/doc/node_red_edit_ip_addr.png -------------------------------------------------------------------------------- /doc/node_red_import.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jens18/lgresu/1d330b5add3af7d02393cad9c88c24a4b02882b2/doc/node_red_import.png -------------------------------------------------------------------------------- /doc/node_red_manage_palette.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jens18/lgresu/1d330b5add3af7d02393cad9c88c24a4b02882b2/doc/node_red_manage_palette.png -------------------------------------------------------------------------------- /lgresustatus/lgresustatus.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Jens Kaemmerer. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Package lgresustatus provides routines to decode LG Resu 10 LV 16 | // CANBus messages and generate the 'keep alive' message (typically 17 | // send from an CANBus enabled device (for example the Schneider Conext 18 | // Bridge or directly from an inverter) to the LG Resu 10 LV. The decoded 19 | // result can be converted into a JSON message. 20 | // 21 | // Note: 22 | // 23 | // Not all messages can currently be decoded. Support for 24 | // alarm bits (message id 0x359) and CANBus message id 0x354 is missing. 25 | // 26 | // CANBus BMS Message format specifications: 27 | // 28 | // 1) Lithiumate BMS CANBus message format specification: 29 | // 30 | // http://lithiumate.elithion.com/php/controller_can_specs.php 31 | // 32 | // 2) LG Resu 10 LV CANBus message format specification: 33 | // 34 | // https://www.photovoltaikforum.com/speichersysteme-ongrid-netzparallel-f137/reverse-engineering-bms-von-lg-chem-resu-6-4ex-ned-t108629-s10.html 35 | // 36 | package lgresustatus 37 | 38 | import ( 39 | "encoding/binary" 40 | log "github.com/sirupsen/logrus" 41 | "strconv" 42 | "time" 43 | ) 44 | 45 | // Definition of the LG Resu 10 CANBus message id's 46 | const ( 47 | INV_KEEP_ALIVE uint32 = 0x305 48 | BMS_LIMITS uint32 = 0x351 49 | BMS_SERIAL_NUM uint32 = 0x354 50 | BMS_SOC_SOH uint32 = 0x355 51 | BMS_VOLT_AMP_TEMP uint32 = 0x356 52 | BMS_WARN_ALARM uint32 = 0x359 53 | ) 54 | 55 | // Github triggers update of godoc documentation. 56 | type Github int 57 | 58 | // BitValue contains a single warning/alarm bit mask and definition. 59 | type BitValue struct { 60 | Description string 61 | Value uint16 62 | } 63 | 64 | // WarningBitValues defines 16 warning bits. 65 | // 66 | // Raw CANBus message format: 67 | // 68 | // 00000359 8 ww WW aa AA 00 00 00 00 69 | // 70 | // ww0 WRN_ONLY_SUB_RELAY_COMMAND 71 | // ww1 BATTERY_HIGH_VOLTAGE 72 | // ww2 BATTERY_LOW_VOLTAGE 73 | // ww3 BATTERY_HIGH_TEMP 74 | // ww4 BATTERY_LOW_TEMP 75 | // ww5 UNKNOWN 76 | // ww6 UNKNOWN 77 | // ww7 BATTERY_HIGH_CURRENT_DISCHARGE 78 | // WW0 BATTERY_HIGH_CURRENT_CHARGE 79 | // WW1 UNKNOWN 80 | // WW2 UNKNOWN 81 | // WW3 BMS_INTERNAL 82 | // WW4 CELL_IMBALANCE 83 | // WW5 ALARM_SUB_PACK2_ERROR 84 | // WW6 ALARM_SUB_PACK1_ERROR 85 | // WW7 UNKNOWN 86 | // 87 | // Note: 88 | // Bitmasks are applied after converting the littleEndian representation 89 | // of the first 2 bytes to the bigEndian representation. 90 | var WarningBitValues = []BitValue{ 91 | {"WRN_ONLY_SUB_RELAY_COMMAND", 0x0001}, 92 | {"BATTERY_HIGH_VOLTAGE", 0x0002}, 93 | {"BATTERY_LOW_VOLTAGE", 0x0004}, 94 | {"BATTERY_HIGH_TEMP", 0x0008}, 95 | {"BATTERY_LOW_TEMP", 0x0010}, 96 | {"UNKNOWN_ww5", 0x0020}, 97 | {"UNKNOWN_ww6", 0x0040}, 98 | {"BATTERY_HIGH_CURRENT_DISCHARGE", 0x0080}, 99 | {"BATTERY_HIGH_CURRENT_CHARGE", 0x0100}, 100 | {"UNKNOWN_WW1", 0x0200}, 101 | {"UNKNOWN_WW2", 0x0400}, 102 | {"BMS_INTERNAL", 0x0800}, 103 | {"CELL_IMBALANCE", 0x1000}, 104 | {"ALARM_SUB_PACK2_ERROR", 0x2000}, 105 | {"ALARM_SUB_PACK1_ERROR", 0x4000}, 106 | {"UNKNOWN_WW7", 0x8000}, 107 | } 108 | 109 | // AlarmBitValues defines 16 alarm bits (currently unknown). 110 | // 111 | // Raw CANBus message format: 112 | // 113 | // 00000359 8 ww WW aa AA 00 00 00 00 114 | // 115 | // aa0-7 UNKNOWN 116 | // AA0-7 UNKNOWN 117 | // 118 | var AlarmBitValues = []BitValue{ 119 | {"UNKNOWN_ALARM", 0xffff}, 120 | } 121 | 122 | // LgResuStatus contains metrics send by the LG Resu 10 LV. 123 | type LgResuStatus struct { 124 | // State Of Charge 125 | Soc uint16 `json:"soc"` 126 | // State Of Health 127 | Soh uint16 `json:"soh"` 128 | // Current battery voltage 129 | Voltage float32 `json:"voltage"` 130 | // Current battery current (positive value: battery charge current, 131 | // negative value: battery discharge current) 132 | Current float32 `json:"current"` 133 | // Battery temperature 134 | Temp float32 `json:"temp"` 135 | // Battery voltage limit (LG Resu 10 LV is a 14S battery, indiv. cell voltage allowed is 136 | // 4.12V -> 14 * 4.12V = 57.7V) 137 | MaxVoltage float32 `json:"maxVoltage"` 138 | // Maximal battery charge current (LG Resu 10 LV is a C = 189Ah battery, C/2 is approx. 90A) 139 | MaxChargeCurrent float32 `json:"maxChargeCurrent"` 140 | // Maximal battery discharge current (LG Resu 10 is a C = 189Ah battery, C/2 is approx. 90A) 141 | MaxDischargeCurrent float32 `json:"maxDischargeCurrent"` 142 | Warnings []string `json:"warnings"` 143 | Alarms []string `json:"alarms"` 144 | } 145 | 146 | // DecodeLgResuCanbusMessage decodes messages send by the LG Resu 10 LV BMS and updates lgResu with new metric values. 147 | func (lgResu *LgResuStatus) DecodeLgResuCanbusMessage(id uint32, s []byte) { 148 | 149 | log.Debugf("%-4x % -24X\n", id, s) 150 | 151 | switch id { 152 | case BMS_VOLT_AMP_TEMP: 153 | log.Debugf("BMS: volt/amp/temp (%#04x)\n", BMS_VOLT_AMP_TEMP) 154 | 155 | // unsigned: voltage is always positive 156 | data := binary.LittleEndian.Uint16(s[0:2]) 157 | lgResu.Voltage = float32(data) / 100 158 | log.Debugf("voltage = %.2f [VDC]\n", lgResu.Voltage) 159 | 160 | // signed: - battery is discharged, + battery is charged 161 | data = binary.LittleEndian.Uint16(s[2:4]) 162 | lgResu.Current = float32(int16(data)) / 10 163 | log.Debugf("current = %.2f [ADC]\n", lgResu.Current) 164 | 165 | // signed: temperature in Celsius 166 | data = binary.LittleEndian.Uint16(s[4:6]) 167 | lgResu.Temp = float32(data) / 10 168 | log.Debugf("temperature = %.1f [Celsius]\n\n", float32(data)/10) 169 | 170 | case BMS_SOC_SOH: 171 | log.Debugf("BMS: state of charge/health (%#04x):\n", BMS_SOC_SOH) 172 | 173 | data := binary.LittleEndian.Uint16(s[0:2]) 174 | lgResu.Soc = data 175 | log.Debugf("soc = %d %%\n", lgResu.Soc) 176 | 177 | data = binary.LittleEndian.Uint16(s[2:4]) 178 | lgResu.Soh = data 179 | log.Debugf("soh = %d %%\n\n", lgResu.Soh) 180 | 181 | case BMS_LIMITS: 182 | log.Debugf("BMS: configuration parameters (%#04x):\n", BMS_LIMITS) 183 | 184 | // unsigned: voltage is always positive 185 | data := binary.LittleEndian.Uint16(s[0:2]) 186 | lgResu.MaxVoltage = float32(data) / 10 187 | log.Debugf("max voltage = %.2f [VDC]\n", lgResu.MaxVoltage) 188 | 189 | // unsigned: ADC 190 | data = binary.LittleEndian.Uint16(s[2:4]) 191 | lgResu.MaxChargeCurrent = float32(data) / 10 192 | log.Debugf("max charge current = %.2f [ADC]\n", lgResu.MaxChargeCurrent) 193 | 194 | // unsigned: ADC 195 | data = binary.LittleEndian.Uint16(s[4:6]) 196 | lgResu.MaxDischargeCurrent = float32(data) / 10 197 | log.Debugf("max discharge current = %.2f [ADC]\n\n", lgResu.MaxDischargeCurrent) 198 | 199 | case BMS_SERIAL_NUM: 200 | log.Debugf("BMS: serial number (?) (%#04x):\n\n", BMS_SERIAL_NUM) 201 | 202 | case INV_KEEP_ALIVE: 203 | log.Debugf("INV: keep alive (%#04x):\n\n", INV_KEEP_ALIVE) 204 | 205 | case BMS_WARN_ALARM: 206 | log.Debugf("BMS: warnings/alarms (%#04x):\n\n", BMS_WARN_ALARM) 207 | 208 | // decode warnings 209 | data := binary.LittleEndian.Uint16(s[0:2]) 210 | for _, bv := range WarningBitValues { 211 | if data&bv.Value != 0 { 212 | lgResu.Warnings = append(lgResu.Warnings, bv.Description) 213 | } 214 | } 215 | 216 | // decode alarms 217 | data = binary.LittleEndian.Uint16(s[2:4]) 218 | for _, bv := range AlarmBitValues { 219 | if data&bv.Value != 0 { 220 | lgResu.Alarms = append(lgResu.Alarms, bv.Description) 221 | } 222 | } 223 | 224 | } 225 | } 226 | 227 | // CreateKeepAliveMessage creates one 'keep alive' message (to be send to the LG Resu 10 LV). 228 | func (lgResu *LgResuStatus) CreateKeepAliveMessage() (id uint32, s []byte) { 229 | id = INV_KEEP_ALIVE 230 | s = []byte{0x00, 0x0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} 231 | return id, s 232 | } 233 | 234 | // CsvRecord return a string containing the key metrics (Timestamp, SOC, Voltage, Current) as CSV values. 235 | func (lgResu *LgResuStatus) CsvRecord(t time.Time) (csvRecord string) { 236 | 237 | return t.Format("2006/01/02 15:04:05") + "," + 238 | strconv.Itoa(int(lgResu.Soc)) + "," + 239 | strconv.FormatFloat(float64(lgResu.Voltage), 'f', 2, 32) + "," + 240 | strconv.FormatFloat(float64(lgResu.Current), 'f', 2, 32) + "\n" 241 | } 242 | 243 | // CsvRecordHeader return a string containing the header for the CSV data record created with CsvRecord(). 244 | func CsvRecordHeader() (csvRecordHeader string) { 245 | return "Time,Soc,Voltage,Current\n" 246 | } 247 | -------------------------------------------------------------------------------- /lgresustatus/lgresustatus_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2018 Jens Kaemmerer. All Rights Reserved. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package lgresustatus 16 | 17 | import ( 18 | "encoding/csv" 19 | "encoding/json" 20 | "github.com/google/go-cmp/cmp" 21 | log "github.com/sirupsen/logrus" 22 | "strings" 23 | "testing" 24 | "time" 25 | ) 26 | 27 | // LG Resu 10 LV CANBus test messages 28 | var CanbusTestMessages = []struct { 29 | Identifier uint32 30 | Data [8]byte 31 | Expect LgResuStatus 32 | }{ 33 | // volt/amp/temp (LG Resu -> Inverter): 34 | { 35 | Identifier: BMS_VOLT_AMP_TEMP, 36 | Data: [8]byte{0x4b, 0x15, 0xed, 0xff, 0xba, 0x00, 0x00, 0x00}, 37 | Expect: LgResuStatus{Voltage: 54.51, Current: -1.9, Temp: 18.6}, 38 | }, 39 | // ? (LG Resu -> Inverter): unknown message type (appears to be a constant) 40 | { 41 | Identifier: BMS_SERIAL_NUM, 42 | Data: [8]byte{0x04, 0xc0, 0x00, 0x1f, 0x03, 0x00, 0x00, 0x00}, 43 | Expect: LgResuStatus{Voltage: 54.51, Current: -1.9, Temp: 18.6}, 44 | }, 45 | // configuration parameters (LG Resu -> Inverter): 46 | { 47 | Identifier: BMS_LIMITS, 48 | Data: [8]byte{0x41, 0x02, 0x96, 0x03, 0x96, 0x03, 0x00, 0x00}, 49 | Expect: LgResuStatus{Voltage: 54.51, Current: -1.9, Temp: 18.6, 50 | MaxVoltage: 57.70, MaxChargeCurrent: 91.80, MaxDischargeCurrent: 91.80}, 51 | }, 52 | // state of charge/health (LG Resu -> Inverter): 53 | { 54 | Identifier: BMS_SOC_SOH, 55 | Data: [8]byte{0x4d, 0x00, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00}, 56 | Expect: LgResuStatus{Voltage: 54.51, Current: -1.9, Temp: 18.6, 57 | MaxVoltage: 57.70, MaxChargeCurrent: 91.80, MaxDischargeCurrent: 91.80, 58 | Soc: 77, Soh: 99}, 59 | }, 60 | // warnings/alarms (LG Resu -> Inverter): 61 | { 62 | Identifier: BMS_WARN_ALARM, 63 | Data: [8]byte{0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00}, 64 | Expect: LgResuStatus{Voltage: 54.51, Current: -1.9, Temp: 18.6, 65 | MaxVoltage: 57.70, MaxChargeCurrent: 91.80, MaxDischargeCurrent: 91.80, 66 | Soc: 77, Soh: 99, 67 | Warnings: []string{"WRN_ONLY_SUB_RELAY_COMMAND", "BATTERY_HIGH_VOLTAGE", "BATTERY_LOW_VOLTAGE", 68 | "BATTERY_HIGH_TEMP", "BATTERY_LOW_TEMP", "UNKNOWN_ww5", "UNKNOWN_ww6", "BATTERY_HIGH_CURRENT_DISCHARGE", 69 | "BATTERY_HIGH_CURRENT_CHARGE", "UNKNOWN_WW1", "UNKNOWN_WW2", "BMS_INTERNAL", "CELL_IMBALANCE", 70 | "ALARM_SUB_PACK2_ERROR", "ALARM_SUB_PACK1_ERROR", "UNKNOWN_WW7"}, 71 | Alarms: []string{"UNKNOWN_ALARM"}, 72 | }, 73 | }, 74 | } 75 | 76 | var JsonExpectMessage string = `{"soc":77,"soh":99,"voltage":54.51,"current":-1.9,"temp":18.6,"maxVoltage":57.7,"maxChargeCurrent":91.8,"maxDischargeCurrent":91.8,"warnings":["WRN_ONLY_SUB_RELAY_COMMAND","BATTERY_HIGH_VOLTAGE","BATTERY_LOW_VOLTAGE","BATTERY_HIGH_TEMP","BATTERY_LOW_TEMP","UNKNOWN_ww5","UNKNOWN_ww6","BATTERY_HIGH_CURRENT_DISCHARGE","BATTERY_HIGH_CURRENT_CHARGE","UNKNOWN_WW1","UNKNOWN_WW2","BMS_INTERNAL","CELL_IMBALANCE","ALARM_SUB_PACK2_ERROR","ALARM_SUB_PACK1_ERROR","UNKNOWN_WW7"],"alarms":["UNKNOWN_ALARM"]}` 77 | 78 | var CsvRecordExpect string = "2018/06/11 00:00:00,77,54.51,-1.90\n" 79 | 80 | var CsvRecordHeaderExpect string = "Time,Soc,Voltage,Current\n" 81 | 82 | func init() { 83 | // only log warning severity or above. 84 | log.SetLevel(log.WarnLevel) 85 | } 86 | 87 | func TestDecodeLgResuCanbusMessageToUpdateLgResuStatus(t *testing.T) { 88 | 89 | lgResu := &LgResuStatus{} 90 | 91 | // process all test messages 92 | for _, tm := range CanbusTestMessages { 93 | lgResu.DecodeLgResuCanbusMessage(tm.Identifier, tm.Data[:]) 94 | if !cmp.Equal(*lgResu, tm.Expect) { 95 | t.Errorf("lgResu.DecodeLgResuCanbusMessage(%x, %+v) == %+v, expect %+v", tm.Identifier, tm.Data, *lgResu, tm.Expect) 96 | } 97 | } 98 | } 99 | 100 | func TestLgResuStatusConversionToJson(t *testing.T) { 101 | 102 | lgResu := &LgResuStatus{} 103 | 104 | // process all test messages 105 | for _, tm := range CanbusTestMessages { 106 | lgResu.DecodeLgResuCanbusMessage(tm.Identifier, tm.Data[:]) 107 | } 108 | 109 | jsonMessage, err := json.Marshal(*lgResu) 110 | if err != nil { 111 | log.Fatalf("json.MarshalIndent failed with '%s'\n", err) 112 | } 113 | 114 | if string(jsonMessage) != JsonExpectMessage { 115 | t.Errorf("LgResuStatus in compact JSON == %s, expect %s\n", string(jsonMessage), JsonExpectMessage) 116 | } 117 | } 118 | 119 | func TestCreateKeepAliveMessage(t *testing.T) { 120 | lgResu := &LgResuStatus{} 121 | 122 | id, data := lgResu.CreateKeepAliveMessage() 123 | 124 | if (id != INV_KEEP_ALIVE) || (len(data) != 8) { 125 | t.Errorf("CreateKeepAliveMessage() returned id = %#04x, len(data) = %d, expect id = %#04x, len(data) = 8 \n", 126 | id, len(data), INV_KEEP_ALIVE) 127 | } 128 | } 129 | 130 | func TestCsvRecord(t *testing.T) { 131 | lgResu := &LgResuStatus{} 132 | 133 | // process all test messages 134 | for _, tm := range CanbusTestMessages { 135 | lgResu.DecodeLgResuCanbusMessage(tm.Identifier, tm.Data[:]) 136 | } 137 | 138 | timestamp, _ := time.Parse("2006-Jan-02", "2018-Jun-11") 139 | 140 | csvRecord := lgResu.CsvRecord(timestamp) 141 | log.Debugf("TestCsvRecord: csvRecord = %s \n", csvRecord) 142 | 143 | csvHeader := CsvRecordHeader() 144 | 145 | if csvRecord != CsvRecordExpect { 146 | t.Errorf("TestCsvRecord() returned CSV data record: %s, expect CSV data record: %s\n", 147 | csvRecord, CsvRecordExpect) 148 | } 149 | 150 | // number of CSV values should match number of CSV header values (4) 151 | reader := csv.NewReader(strings.NewReader(csvRecord)) 152 | record, _ := reader.Read() 153 | 154 | reader = csv.NewReader(strings.NewReader(csvHeader)) 155 | header, _ := reader.Read() 156 | 157 | if len(record) != len(header) { 158 | t.Errorf("TestCsvRecord() returned CSV data record with %d values, expect CSV data record with %d values\n", 159 | len(record), len(header)) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /script/can_stats.sh: -------------------------------------------------------------------------------- 1 | ip -details -statistics link show can0 2 | -------------------------------------------------------------------------------- /script/keep_alive.sh: -------------------------------------------------------------------------------- 1 | while sleep 20 2 | do 3 | echo "keep_alive.sh: sending keep alive message (305): "; date; 4 | cansend can0 305#0000000000000000; 5 | done 6 | -------------------------------------------------------------------------------- /script/lg_resu_dashboard.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "199b594a.56b447", 4 | "type": "debug", 5 | "z": "1140ba0.991d8c6", 6 | "name": "", 7 | "active": true, 8 | "console": "false", 9 | "complete": "true", 10 | "x": 777.6428833007812, 11 | "y": 231.14285278320312, 12 | "wires": [] 13 | }, 14 | { 15 | "id": "6e7ea4dd.72241c", 16 | "type": "http request", 17 | "z": "1140ba0.991d8c6", 18 | "name": "", 19 | "method": "GET", 20 | "ret": "txt", 21 | "url": "192.168.29.30:9090", 22 | "tls": "", 23 | "x": 340.64288330078125, 24 | "y": 270.1428527832031, 25 | "wires": [ 26 | [ 27 | "e0ba7308.8aef1" 28 | ] 29 | ] 30 | }, 31 | { 32 | "id": "b8d87640.6e4c28", 33 | "type": "inject", 34 | "z": "1140ba0.991d8c6", 35 | "name": "", 36 | "topic": "", 37 | "payload": "", 38 | "payloadType": "date", 39 | "repeat": "300", 40 | "crontab": "", 41 | "once": false, 42 | "onceDelay": "", 43 | "x": 146.64288330078125, 44 | "y": 271.1428527832031, 45 | "wires": [ 46 | [ 47 | "6e7ea4dd.72241c" 48 | ] 49 | ] 50 | }, 51 | { 52 | "id": "e0ba7308.8aef1", 53 | "type": "json", 54 | "z": "1140ba0.991d8c6", 55 | "name": "", 56 | "property": "payload", 57 | "action": "", 58 | "pretty": false, 59 | "x": 533.1428833007812, 60 | "y": 271.1428527832031, 61 | "wires": [ 62 | [ 63 | "f5b2bf92.0c75d", 64 | "12737983.bab48e", 65 | "199b594a.56b447", 66 | "5645cc7b.db558c", 67 | "69a04ae5.bb4ea4", 68 | "f32c872d.3f2c9" 69 | ] 70 | ] 71 | }, 72 | { 73 | "id": "4a0ac3f7.4642c4", 74 | "type": "ui_gauge", 75 | "z": "1140ba0.991d8c6", 76 | "name": "", 77 | "group": "14d778f.1663a87", 78 | "order": 1, 79 | "width": 0, 80 | "height": 0, 81 | "gtype": "gage", 82 | "title": "SOC", 83 | "label": "%", 84 | "format": "{{value}}", 85 | "min": 0, 86 | "max": "100", 87 | "colors": [ 88 | "#00b500", 89 | "#e6e600", 90 | "#ca3838" 91 | ], 92 | "seg1": "", 93 | "seg2": "", 94 | "x": 1030.1428833007812, 95 | "y": 411.1428527832031, 96 | "wires": [] 97 | }, 98 | { 99 | "id": "a72b6988.ec8368", 100 | "type": "ui_text", 101 | "z": "1140ba0.991d8c6", 102 | "group": "14d778f.1663a87", 103 | "order": 4, 104 | "width": 0, 105 | "height": 0, 106 | "name": "", 107 | "label": "Voltage", 108 | "format": "{{msg.payload}}", 109 | "layout": "row-spread", 110 | "x": 1029.1428833007812, 111 | "y": 580.1428527832031, 112 | "wires": [] 113 | }, 114 | { 115 | "id": "f5b2bf92.0c75d", 116 | "type": "change", 117 | "z": "1140ba0.991d8c6", 118 | "name": "", 119 | "rules": [ 120 | { 121 | "t": "set", 122 | "p": "payload", 123 | "pt": "msg", 124 | "to": "payload.soc", 125 | "tot": "msg" 126 | } 127 | ], 128 | "action": "", 129 | "property": "", 130 | "from": "", 131 | "to": "", 132 | "reg": false, 133 | "x": 760.1428833007812, 134 | "y": 411.1428527832031, 135 | "wires": [ 136 | [ 137 | "4a0ac3f7.4642c4" 138 | ] 139 | ] 140 | }, 141 | { 142 | "id": "12737983.bab48e", 143 | "type": "change", 144 | "z": "1140ba0.991d8c6", 145 | "name": "", 146 | "rules": [ 147 | { 148 | "t": "set", 149 | "p": "payload", 150 | "pt": "msg", 151 | "to": "payload.voltage", 152 | "tot": "msg" 153 | } 154 | ], 155 | "action": "", 156 | "property": "", 157 | "from": "", 158 | "to": "", 159 | "reg": false, 160 | "x": 653.1428833007812, 161 | "y": 578.1428527832031, 162 | "wires": [ 163 | [ 164 | "a72b6988.ec8368" 165 | ] 166 | ] 167 | }, 168 | { 169 | "id": "5645cc7b.db558c", 170 | "type": "change", 171 | "z": "1140ba0.991d8c6", 172 | "name": "", 173 | "rules": [ 174 | { 175 | "t": "set", 176 | "p": "payload", 177 | "pt": "msg", 178 | "to": "payload.current", 179 | "tot": "msg" 180 | } 181 | ], 182 | "action": "", 183 | "property": "", 184 | "from": "", 185 | "to": "", 186 | "reg": false, 187 | "x": 618.1428833007812, 188 | "y": 661.1428527832031, 189 | "wires": [ 190 | [ 191 | "84a1f844.86821" 192 | ] 193 | ] 194 | }, 195 | { 196 | "id": "84a1f844.86821", 197 | "type": "ui_text", 198 | "z": "1140ba0.991d8c6", 199 | "group": "14d778f.1663a87", 200 | "order": 5, 201 | "width": 0, 202 | "height": 0, 203 | "name": "", 204 | "label": "Current", 205 | "format": "{{msg.payload}}", 206 | "layout": "row-spread", 207 | "x": 1031.1428833007812, 208 | "y": 659.1428527832031, 209 | "wires": [] 210 | }, 211 | { 212 | "id": "69a04ae5.bb4ea4", 213 | "type": "change", 214 | "z": "1140ba0.991d8c6", 215 | "name": "", 216 | "rules": [ 217 | { 218 | "t": "set", 219 | "p": "payload", 220 | "pt": "msg", 221 | "to": "payload.temp", 222 | "tot": "msg" 223 | } 224 | ], 225 | "action": "", 226 | "property": "", 227 | "from": "", 228 | "to": "", 229 | "reg": false, 230 | "x": 583.1428833007812, 231 | "y": 738.1428527832031, 232 | "wires": [ 233 | [ 234 | "c137a54e.0397b" 235 | ] 236 | ] 237 | }, 238 | { 239 | "id": "c137a54e.0397b", 240 | "type": "ui_text", 241 | "z": "1140ba0.991d8c6", 242 | "group": "14d778f.1663a87", 243 | "order": 6, 244 | "width": 0, 245 | "height": 0, 246 | "name": "", 247 | "label": "Temp", 248 | "format": "{{msg.payload}}", 249 | "layout": "row-spread", 250 | "x": 1025.1428833007812, 251 | "y": 734.1428527832031, 252 | "wires": [] 253 | }, 254 | { 255 | "id": "e6b6add3.60886", 256 | "type": "ui_chart", 257 | "z": "1140ba0.991d8c6", 258 | "name": "", 259 | "group": "14d778f.1663a87", 260 | "order": 2, 261 | "width": 0, 262 | "height": 0, 263 | "label": "", 264 | "chartType": "line", 265 | "legend": "false", 266 | "xformat": "HH:mm:ss", 267 | "interpolate": "linear", 268 | "nodata": "", 269 | "dot": false, 270 | "ymin": "0", 271 | "ymax": "100", 272 | "removeOlder": 1, 273 | "removeOlderPoints": "", 274 | "removeOlderUnit": "86400", 275 | "cutout": 0, 276 | "useOneColor": false, 277 | "colors": [ 278 | "#1f77b4", 279 | "#aec7e8", 280 | "#ff7f0e", 281 | "#2ca02c", 282 | "#98df8a", 283 | "#d62728", 284 | "#ff9896", 285 | "#9467bd", 286 | "#c5b0d5" 287 | ], 288 | "useOldStyle": false, 289 | "x": 1029.5, 290 | "y": 464, 291 | "wires": [ 292 | [], 293 | [] 294 | ] 295 | }, 296 | { 297 | "id": "f32c872d.3f2c9", 298 | "type": "change", 299 | "z": "1140ba0.991d8c6", 300 | "name": "", 301 | "rules": [ 302 | { 303 | "t": "set", 304 | "p": "payload", 305 | "pt": "msg", 306 | "to": "payload.soc", 307 | "tot": "msg" 308 | } 309 | ], 310 | "action": "", 311 | "property": "", 312 | "from": "", 313 | "to": "", 314 | "reg": false, 315 | "x": 755, 316 | "y": 465, 317 | "wires": [ 318 | [ 319 | "e6b6add3.60886" 320 | ] 321 | ] 322 | }, 323 | { 324 | "id": "14d778f.1663a87", 325 | "type": "ui_group", 326 | "z": "", 327 | "name": "Dashboard", 328 | "tab": "f34576ca.de4738", 329 | "disp": true, 330 | "width": "6", 331 | "collapse": false 332 | }, 333 | { 334 | "id": "f34576ca.de4738", 335 | "type": "ui_tab", 336 | "z": "", 337 | "name": "LG Resu Monitor", 338 | "icon": "dashboard" 339 | } 340 | ] 341 | -------------------------------------------------------------------------------- /script/start_interface.sh: -------------------------------------------------------------------------------- 1 | # configure CANBus interface 2 | /sbin/ip link set can0 type can bitrate 500000 restart-ms 100 3 | /sbin/ifconfig can0 up 4 | /sbin/ifconfig can0 5 | /usr/bin/candump -n 5 can0 6 | -------------------------------------------------------------------------------- /script/start_lg_resu_mon.sh: -------------------------------------------------------------------------------- 1 | 2 | LGRESU_MON_HOME=/opt/lgresu 3 | 4 | cd ${LGRESU_MON_HOME} 5 | 6 | mkdir -p log 7 | mkdir -p data 8 | 9 | nohup ./bin/lg_resu_mon -if can0 -d info -p 9090 -dr ${LGRESU_MON_HOME}/data -r 7 > ./log/lg_resu_mon.log 2>&1 & 10 | 11 | 12 | --------------------------------------------------------------------------------