├── .gitignore ├── api_request.py ├── app.py ├── blackbox.py ├── bms.py ├── bms_dummy.py ├── bms_us2000.py ├── config.py ├── demo_multiplus2.py ├── demo_pylontech.py ├── demo_us2000.py ├── development ├── api_state_idle.json ├── info.txt └── wirkungsgrad.ods ├── doc ├── ess_config.png ├── ess_hw_block.png ├── ess_sw_block.png ├── ess_trace_feed.png └── ess_web_app.png ├── ess.py ├── ess.service ├── fsm.py ├── icon ├── apple-touch-icon-120x120.png ├── ess-icon.svg ├── favicon.ico └── favicon.png ├── multiplus2.py ├── pylontech.py ├── readme.md ├── requirements.txt ├── resources ├── ban.svg ├── bolt-lightning.svg ├── bolt.svg ├── car-battery.svg ├── car.svg ├── check.svg ├── chevron-left.svg ├── chevron-right.svg ├── circle-minus.svg ├── circle-xmark.svg ├── clock1.svg ├── clock2.svg ├── cloud.svg ├── gear.svg ├── house.svg ├── industry.svg └── sun.svg ├── session.py ├── timer.py ├── trace.py ├── us2000.py ├── utils.py ├── vebus.py ├── version.py ├── web.py └── www ├── app.js ├── apple-touch-icon-120x120.png ├── favicon.ico ├── index.html ├── login.html ├── nobs.css ├── pflow.js ├── trace-charge.html ├── trace-feed.html └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __pycache__ 3 | /log/ 4 | /release/ 5 | /www/lib/chart.js-3.8.0/ 6 | /debug/ 7 | -------------------------------------------------------------------------------- /api_request.py: -------------------------------------------------------------------------------- 1 | # 09.04.2022 Martin Steppuhn 2 | # 15.08.2022 Martin Steppuhn error in answer dictionary 3 | 4 | import json 5 | import logging 6 | import time 7 | import requests 8 | 9 | class ApiRequest: 10 | """ 11 | Simplified HTTP Request for JSON APIs. 12 | 13 | Sends an HTTP request. The response (JSON) is converted to a dictionary. 14 | With Lifetime a timeout for data can be specified. Even if there is an error or no read, data is still valid for 15 | the specified period. 16 | """ 17 | 18 | def __init__(self, url, timeout=1, lifetime=10, log_name='api'): 19 | """ 20 | :param url: string 21 | :param timeout: timeout for request in seconds 22 | :param lifetime: timeout for data in seconds 23 | :param log_name: name for logger 24 | """ 25 | self.url = url 26 | self.timeout = timeout 27 | self.lifetime = lifetime 28 | self.log = logging.getLogger(log_name) 29 | self.data = None # Data 30 | self.lifetime_timeout = time.perf_counter() + self.lifetime if self.lifetime else None # set lifetime timeout 31 | self.log.debug("init url: {}".format(url)) 32 | 33 | def read(self, post=None, url_extension=''): 34 | """ 35 | Read 36 | 37 | :return: data (dictionary) 38 | """ 39 | 40 | t0 = time.perf_counter() 41 | error = None 42 | data = None 43 | url = "" 44 | try: 45 | url = self.url + url_extension 46 | if post is None: 47 | r = requests.get(url, timeout=self.timeout) 48 | else: 49 | r = requests.post(url, timeout=self.timeout, json=post) 50 | if r.status_code == 200: 51 | data = json.loads(r.content) 52 | else: 53 | raise ValueError("status_code={} url={}".format(r.status_code, url)) 54 | 55 | self.lifetime_timeout = t0 + self.lifetime if self.lifetime else None # set new lifetime timeout 56 | self.data = data # update data 57 | self.log.debug("read done in {:.3f}s data: {}".format(time.perf_counter() - t0, data)) 58 | 59 | except requests.ConnectionError: 60 | error = "connection to {} failed ({:.3f}s)".format(url, time.perf_counter() - t0) 61 | self.log.error(error) 62 | data = {'error': error} 63 | 64 | except Exception as e: 65 | error = "read to {} failed ({:.3f}s)".format(url, time.perf_counter() - t0) 66 | self.log.error(error + " error: {}".format(e)) 67 | data = {'error': error} 68 | 69 | if self.lifetime: 70 | if self.lifetime_timeout and time.perf_counter() > self.lifetime_timeout: 71 | self.lifetime_timeout = None # disable timeout, restart with next valid receive 72 | self.data = data 73 | else: 74 | self.data = data # without lifetime set self.data instantly to read result 75 | 76 | return data 77 | 78 | def get(self, key, default=None): 79 | """ 80 | Get a value from data. 81 | 82 | :param key: String (key for data dictionary, or list with nested keys and index) 83 | :param default: return für invalid get 84 | :return: value 85 | """ 86 | try: 87 | value = self.data 88 | if isinstance(key, (tuple, list)): 89 | for k in key: 90 | value = value[k] 91 | else: 92 | value = value[key] 93 | return value 94 | except: 95 | return default 96 | 97 | 98 | if __name__ == "__main__": 99 | logging.basicConfig( 100 | level=logging.DEBUG, 101 | format='%(asctime)s %(name)-10s %(levelname)-6s %(message)s', 102 | datefmt='%Y-%m-%d %H:%M:%S', 103 | ) 104 | logging.getLogger("urllib3.connectionpool").setLevel(logging.INFO) 105 | 106 | api = ApiRequest("http://192.168.0.10:8008", timeout=1, lifetime=10) 107 | # api = ApiRequest("http://192.168.50.50/api/system/status", timeout=1, lifetime=10) 108 | while True: 109 | data = api.read() 110 | print(data, api.data) 111 | time.sleep(3) 112 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from copy import deepcopy 4 | from datetime import datetime 5 | 6 | import version 7 | from api_request import ApiRequest 8 | from blackbox import Blackbox 9 | from config import config 10 | from fsm import FSM 11 | from multiplus2 import MultiPlus2 12 | from timer import Timer 13 | from trace import Trace 14 | from bms_us2000 import US2000 15 | from utils import * 16 | from web import AppWeb 17 | 18 | from bms_dummy import BMS_DUMMY 19 | 20 | """ 21 | ESS Application 22 | """ 23 | 24 | 25 | class App(FSM): 26 | def __init__(self): 27 | super().__init__('init') 28 | self.www_path = config['www_path'] 29 | self.log = logging.getLogger('app') 30 | self.web = AppWeb(self) 31 | self.trace = Trace() 32 | self.config = config 33 | self.meterhub = ApiRequest(config['meterhub_address'], timeout=0.5, lifetime=10, log_name='meterhub') 34 | 35 | if 'bms_us2000' in config: 36 | self.bms = US2000(**self.config['bms_us2000']) # pass config to BMS class 37 | elif 'bms_us5000' in config: 38 | self.bms = US2000(**self.config['bms_us5000'], type="US5000") # pass config to BMS class 39 | # elif 'bms_seplos' in config: 40 | # self.bms = SEPLOS(**self.config['bms_seplos']) # pass config to BMS class 41 | else: 42 | self.log.exception("undefined BMS") 43 | self.bms = None 44 | 45 | self.multiplus = MultiPlus2(config['victron_mk3_port']) 46 | self.blackbox = Blackbox(size=config['blackbox_size'], 47 | path=config['log_path'], 48 | csv_config=config.get('csv_log', None)) 49 | 50 | self.mode = 'off' # Operation mode: 'off', 'auto', 'manual' 51 | self.set_p = 0 # power set value 52 | self.setting = 0 # 0, 1, ... Index to usersettings from config/ui 53 | 54 | self.ui_command = None # commands from UI sent with polling, manual commands 55 | self.feed_throttle = False # limit max feed at high continous feed 56 | 57 | # values from MeterHub and BMS 58 | self.grid_p = 0 59 | self.home_p = 0 60 | self.home_all_p = 0 61 | self.car_p = 0 62 | self.pv_p = 0 63 | 64 | self.charge_start_timer = Timer() 65 | self.feed_start_timer = Timer() 66 | self.feed_throttle_timer = Timer() 67 | self.state_timer = Timer() 68 | 69 | 70 | def get_setting(self, name): 71 | """ 72 | Give a setting depending on the option used or default 73 | :param name: key 74 | :return: value 75 | """ 76 | if name in self.config['setting'][self.setting]: 77 | return self.config['setting'][self.setting][name] # use custom setting 78 | else: 79 | return self.config['setting'][0][name] # default or fallback to default 80 | 81 | def start(self): 82 | """ 83 | Application mainloop 84 | """ 85 | self.log.info('start ess {}'.format(version.__version__)) 86 | 87 | while True: 88 | t_begin = time.perf_counter() # cycle start time 89 | 90 | # === meterhub =================================================== ~ 15ms 91 | 92 | self.meterhub.read( 93 | post={'bat_info': self.get_info_text(), 'bat_soc': self.bms.soc}) 94 | self.log.debug("meterhub {}".format(self.meterhub.data)) 95 | 96 | # === bms =================================================== ~ 0ms (Thread) 97 | 98 | self.bms.update() 99 | self.log.debug("bms {}".format(self.bms.get_state())) 100 | 101 | # ================================================================ 102 | 103 | if self._fsm_state not in ('error', 'init'): 104 | self.fsm_switch() 105 | self.update_in() 106 | self.run_fsm() 107 | self.trace.push(deepcopy(self.get_state())) 108 | 109 | # === Multiplus =================================================== 110 | 111 | self.multiplus.command(self.set_p) 112 | time.sleep(0.075) 113 | self.multiplus.update(pause_time=0.075) 114 | self.log.debug("multiplus {}".format(self.multiplus.data)) 115 | 116 | # === Blackbox =================================================== 117 | 118 | self.blackbox.push(self.get_state(bms_detail=True)) 119 | 120 | # ================================================================ 121 | 122 | t_end = time.perf_counter() 123 | while time.perf_counter() < t_begin + 0.75: 124 | time.sleep(0.01) 125 | 126 | # typical ~ 680ms (synced to 750ms) 127 | 128 | # print("loop {:.3f}s/{:.3f}s ".format(t_end - t_begin, time.perf_counter() - t_begin)) 129 | 130 | def update_in(self): 131 | """ 132 | Acquire all incoming data 133 | """ 134 | self.grid_p = dictget(self.meterhub.data, 'grid_p') 135 | self.car_p = dictget(self.meterhub.data, 'car_p') 136 | self.pv_p = dictget(self.meterhub.data, 'pv_p') 137 | self.home_all_p = dictget(self.meterhub.data, 'home_all_p') 138 | 139 | # if self.home_all_p and self.car_p: 140 | # self.home_p = self.home_all_p - self.car_p 141 | if self.home_all_p: 142 | self.home_p = self.home_all_p 143 | else: 144 | self.home_p = None 145 | 146 | 147 | 148 | def fsm_switch(self): 149 | """ 150 | Auto state change by events 151 | """ 152 | if self.bms.voltage and self.bms.voltage > self.config['udc_max']: 153 | self.log.error("error max voltage at bms {}".format(self.bms.get_state())) 154 | self.set_fsm_state('error') 155 | elif self.bms.temperature and self.bms.temperature > self.config['t_max']: 156 | self.log.error("error max bms temperature {}".format(self.bms.get_state())) 157 | self.set_fsm_state('error') 158 | elif not self.is_meterhub_ready(): 159 | self.log.error("meterhub error {}".format(self.meterhub.data)) 160 | self.set_fsm_state('error') 161 | elif self.bms.error: 162 | self.log.error("bms error {}".format(self.bms.get_state())) 163 | self.set_fsm_state('error') 164 | elif not self.is_multiplus_ready(): 165 | self.log.error("multiplus error {}".format(self.multiplus.data)) 166 | self.set_fsm_state('error') 167 | else: 168 | if self.mode == 'off' and self._fsm_state != 'off': 169 | self.set_fsm_state('off') 170 | elif self.mode == 'auto' and not self._fsm_state.startswith('auto_'): 171 | self.set_fsm_state('auto_idle') 172 | elif self.mode == 'manual' and self._fsm_state != 'manual': 173 | self.set_fsm_state('manual') 174 | 175 | def is_charge_start(self): 176 | """ 177 | Check charge start condition 178 | 179 | :return: bool 180 | """ 181 | try: 182 | p = self.pv_p - self.home_all_p - self.get_setting('charge_reserve_power') 183 | except: 184 | p = 0 185 | 186 | if p < self.get_setting('charge_min_power') or self.bms.soc_high is None or self.bms.soc_high > ( 187 | self.get_setting('charge_end_soc') - self.get_setting('charge_hysteresis_soc')): 188 | self.charge_start_timer.stop() 189 | else: 190 | if self.charge_start_timer.is_stop(): 191 | self.charge_start_timer.start(self.get_setting('charge_start_time')) 192 | elif self.charge_start_timer.is_expired(): 193 | # self.charge_start_timer.stop() 194 | return True 195 | return False 196 | 197 | def is_feed_start(self): 198 | """ 199 | Check feed start condition 200 | 201 | :return: bool 202 | """ 203 | 204 | try: 205 | p = self.home_p - self.pv_p - self.get_setting('feed_reserve_power') 206 | except: 207 | p = 0 208 | 209 | if p < self.get_setting('feed_min_power') or self.bms.soc_low is None or self.bms.soc_low < ( 210 | self.get_setting('feed_end_soc') + self.get_setting('feed_hysteresis_soc')): 211 | self.feed_start_timer.stop() 212 | else: 213 | if self.feed_start_timer.is_stop(): 214 | self.feed_start_timer.start(self.get_setting('feed_start_time')) 215 | elif self.feed_start_timer.is_expired(): 216 | # self.feed_start_timer.stop() 217 | return True 218 | return False 219 | 220 | def is_meterhub_ready(self): 221 | return True if self.meterhub.data and 'error' not in self.meterhub.data else False 222 | 223 | def is_multiplus_ready(self): 224 | return True if self.multiplus.data and 'error' not in self.multiplus.data else False 225 | 226 | # ================================================================================================================== 227 | # INIT 228 | # ================================================================================================================== 229 | def fsm_init(self, entry): 230 | if entry: 231 | self.log.info("FSM: INIT") 232 | self.set_p = 0 233 | self.state_timer.start(10) 234 | 235 | if self.is_meterhub_ready() and not self.bms.error and self.is_multiplus_ready(): 236 | self.fsm_switch() 237 | 238 | if self.state_timer.is_expired(): 239 | if not self.is_meterhub_ready(): 240 | self.log.error("meterhub error {}".format(self.meterhub.data)) 241 | if self.bms.error(): 242 | self.log.error("bms error {}".format(self.bms.get_state())) 243 | if not self.is_multiplus_ready(): 244 | self.log.error("multiplus error={}".format(self.multiplus.data)) 245 | self.set_fsm_state('error') 246 | 247 | # ================================================================================================================== 248 | # ERROR 249 | # ================================================================================================================== 250 | def fsm_error(self, entry): 251 | if entry: 252 | self.log.info("FSM: ERROR") 253 | self.blackbox.dump() # save blockbox data to file 254 | self.set_p = 0 255 | 256 | # ================================================================================================================== 257 | # OFF 258 | # ================================================================================================================== 259 | def fsm_off(self, entry): 260 | if entry: 261 | self.log.info("FSM: OFF") 262 | self.set_p = 0 263 | 264 | # ================================================================================================================== 265 | # AUTO_IDLE 266 | # ================================================================================================================== 267 | def fsm_auto_idle(self, entry): 268 | if entry: 269 | self.log.info("AUTO-IDLE") 270 | self.set_p = 0 271 | self.charge_start_timer.stop() 272 | self.feed_start_timer.stop() 273 | if self.get_setting('idle_sleep_time'): # if >0 use sleep 274 | self.state_timer.start(self.get_setting('idle_sleep_time')) 275 | try: 276 | mp2_state = self.multiplus.data['state'] 277 | if (self.is_feed_start() or self.is_charge_start()) and mp2_state == 'sleep': 278 | self.log.info("[auto-idle] wakeup") 279 | self.state_timer.start(self.get_setting('idle_sleep_time')) 280 | self.multiplus.wakeup() 281 | 282 | if self.is_feed_start(): 283 | self.set_fsm_state('auto_feed') 284 | elif self.is_charge_start(): 285 | self.set_fsm_state('auto_charge') 286 | 287 | if self.state_timer.is_expired() and mp2_state != 'sleep': 288 | self.state_timer.start(5) # resend 289 | self.log.info("[auto-idle] sleep") 290 | self.multiplus.sleep() 291 | 292 | except Exception as e: 293 | self.log.error("[auto_idle] exception {}".format(e)) 294 | self.set_fsm_state('error') 295 | 296 | # ================================================================================================================== 297 | # AUTO_CHARGE 298 | # ================================================================================================================== 299 | def fsm_auto_charge(self, entry): 300 | if entry: 301 | self.log.info("AUTO-CHARGE") 302 | try: 303 | p = self.pv_p - self.home_all_p - self.get_setting('charge_reserve_power') 304 | 305 | # print("p={} pv={} home_all={} home={} car={}".format(p, self.pv_p, self.home_all_p, self.home_p, self.car_p) ) 306 | # ToDo Filter schnell runter, langsam hoch 307 | 308 | charge_set_p = limit(p, 0, self.get_setting('charge_max_power')) # limit to 0..max 309 | if self.bms.soc_high and self.bms.soc_high >= self.get_setting('charge_end_soc'): # end by SOC 310 | self.log.info("charge end by soc (config.charge_end_soc)") 311 | self.set_fsm_state('auto_idle') 312 | elif self.bms.voltage and self.bms.voltage >= self.get_setting('charge_end_voltage'): # end by UDC 313 | self.log.info("charge end by voltage (config.charge_end_voltage)") 314 | self.set_fsm_state('auto_idle') 315 | else: 316 | if charge_set_p > self.get_setting('charge_min_power'): 317 | self.state_timer.stop() 318 | else: 319 | if self.state_timer.is_stop(): 320 | self.state_timer.start(self.get_setting('charge_stop_time')) 321 | elif self.state_timer.is_expired(): 322 | self.log.info( 323 | "end by low charge power, home={}W pv={}W".format(self.home_p, self.pv_p)) 324 | self.set_fsm_state('auto_idle') 325 | 326 | self.set_p = charge_set_p 327 | 328 | except Exception as e: 329 | self.log.error("[auto_charge] exception {}".format(e)) 330 | self.set_fsm_state('error') 331 | 332 | # ================================================================================================================== 333 | # AUTO_FEED 334 | # ================================================================================================================== 335 | def fsm_auto_feed(self, entry): 336 | if entry: 337 | self.log.info("AUTO-FEED") 338 | 339 | try: 340 | p = self.home_p - self.pv_p - self.get_setting('feed_reserve_power') 341 | 342 | if self.bms.soc_low and self.bms.soc_low <= 25: 343 | max_p = self.get_setting('feed_soc25_max_power') 344 | else: 345 | max_p = self.get_setting('feed_max_power') 346 | 347 | feed_set_p = limit(p, 0, max_p) # limit to 0..max 348 | 349 | # ------ throttle -------------- 350 | if not self.feed_throttle: 351 | if feed_set_p < self.get_setting('feed_throttle_power'): 352 | self.feed_throttle_timer.stop() 353 | else: 354 | if self.feed_throttle_timer.is_stop(): 355 | self.feed_throttle_timer.start(self.get_setting('feed_throttle_time')) 356 | if self.feed_throttle_timer.is_expired(): 357 | self.feed_throttle = True 358 | self.log.info("feed throttle activated") 359 | else: 360 | if feed_set_p > self.get_setting('feed_throttle_power'): 361 | self.feed_throttle_timer.stop() 362 | else: 363 | if self.feed_throttle_timer.is_stop(): 364 | self.feed_throttle_timer.start(self.get_setting('feed_throttle_time')) 365 | if self.feed_throttle_timer.is_expired(): 366 | self.feed_throttle = False 367 | self.log.info("feed throttle disabled") 368 | 369 | feed_set_p = limit(p, 0, self.get_setting('feed_throttle_power')) 370 | # -------------------------------------------------------------------------------- 371 | 372 | if self.bms.soc_low and self.bms.soc_low <= self.get_setting('feed_end_soc'): # end by SOC 373 | self.log.info("feed end by soc (config.feed_end_soc)") 374 | self.set_fsm_state('auto_idle') 375 | elif self.bms.voltage and self.bms.voltage <= self.get_setting('feed_end_voltage'): # end by UDC 376 | self.log.info("feed end by voltage (config.feed_end_voltage)") 377 | self.set_fsm_state('auto_idle') 378 | else: 379 | if feed_set_p > self.get_setting('feed_min_power'): 380 | self.state_timer.stop() 381 | else: 382 | if self.state_timer.is_stop(): 383 | self.state_timer.start(self.get_setting('feed_stop_time')) 384 | elif self.state_timer.is_expired(): 385 | self.log.info( 386 | "end by low feed power (config.feed_min_power) home={}W pv={}W".format(self.home_p, 387 | self.pv_p)) 388 | self.set_fsm_state('auto_idle') 389 | 390 | self.set_p = -feed_set_p 391 | except Exception as e: 392 | self.log.error("[auto_feed] exception {}".format(e)) 393 | self.set_fsm_state('error') 394 | 395 | # ================================================================================================================== 396 | # MANUAL 397 | # ================================================================================================================== 398 | def fsm_manual(self, entry): 399 | if entry: 400 | self.log.info("MANUAL") 401 | self.set_p = 0 402 | self.state_timer.start(5) 403 | 404 | if self.ui_command and 'manual_set_p' in self.ui_command: 405 | self.state_timer.start(5) 406 | self.log.info("manual command by api: {}".format(self.ui_command)) 407 | try: 408 | self.set_p = self.ui_command['manual_set_p'] 409 | 410 | cmd = self.ui_command.get('manual_cmd', None) 411 | if cmd == 'sleep': 412 | self.log.info("manual multiplus sleep !") 413 | self.multiplus.sleep() 414 | elif cmd == 'wakeup': 415 | self.log.info("manual multiplus wakeup !") 416 | self.multiplus.wakeup() 417 | except: 418 | self.set_p = 0 419 | 420 | self.ui_command = None 421 | 422 | if self.state_timer.is_expired(): 423 | self.log.info("manual timer expired") 424 | self.mode = 'off' 425 | 426 | def get_state(self, bms_detail=False): 427 | """ 428 | Get current state as dictionary (for API) 429 | 430 | :return: dictionary 431 | """ 432 | d = { 433 | 'ess': { 434 | 'mode': self.mode, 435 | 'state': self._fsm_state, 436 | 'set_p': self.set_p, 437 | 'time': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), 438 | 'setting': self.setting, 439 | 'info': self.get_info_text() 440 | }, 441 | 'meterhub': self.meterhub.data, 442 | 'bms': self.bms.get_state(), 443 | 'multiplus': self.multiplus.data, 444 | } 445 | if bms_detail: 446 | d['bms_detail'] = self.bms.get_detail() 447 | return d 448 | 449 | def get_info_text(self): 450 | """ 451 | Get status as german text string 452 | 453 | :return: string 454 | """ 455 | try: 456 | mp2_state = self.multiplus.data['state'] 457 | 458 | s = '[{}, {}, {}]'.format(self.mode, self._fsm_state, mp2_state) 459 | if self.mode == 'off': 460 | s = "AUS" 461 | elif self.mode == 'manual': 462 | s = "HANDBETRIEB !" 463 | elif self._fsm_state == 'auto_idle' and mp2_state == 'sleep': 464 | s = "Automatik - Schlafen" 465 | elif self._fsm_state == 'auto_idle' and mp2_state == 'on': 466 | s = "Automatik - Bereit" 467 | elif self._fsm_state == 'auto_charge' and mp2_state == 'on': 468 | s = "Automatik - Laden" 469 | elif self._fsm_state == 'auto_feed' and mp2_state == 'on': 470 | s = "Automatik - Einspeisen" 471 | elif mp2_state == 'wait': 472 | s = "Automatik - Warten" 473 | return s 474 | except: 475 | return '' 476 | -------------------------------------------------------------------------------- /blackbox.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from datetime import datetime 5 | 6 | from utils import dictget 7 | 8 | 9 | class Blackbox: 10 | """ 11 | Dictionaries are stored in a FIFO. In the event of an error, the memory can be saved to a file. 12 | The number of records held can be specified by length. 13 | 14 | push(): Dataset/dictionary --> JSON 15 | dump(): Save lines of JSON to file, e.g.: blackbox-2022-11-21 144132.jsonl 16 | """ 17 | 18 | def __init__(self, size, path='', csv_config=None): 19 | self.path = path 20 | self.log = logging.getLogger('blackbox') 21 | self.record_lines = [] 22 | self.size = size 23 | os.makedirs(path, exist_ok=True) 24 | 25 | self.csv_config = csv_config 26 | self.csv_interval_time = None 27 | 28 | 29 | 30 | def push(self, dataset): 31 | """ 32 | Convert dataset to JSON, append \n and append to FIFO list. Limit the size to maximum. 33 | :param dataset: dictionary 34 | """ 35 | self.record_lines.append(json.dumps(dataset) + '\n') 36 | self.record_lines = self.record_lines[-self.size:] # limit to latest 37 | 38 | if self.csv_config: 39 | self.csv_push(dataset) 40 | 41 | 42 | def dump(self, prefix='blackbox'): 43 | """ 44 | Save the collected data to a file (Lines of JSON, seperated by \n) 45 | """ 46 | filename = datetime.now().strftime("{}-%Y-%m-%d %H%M%S.jsonl".format(prefix)) 47 | 48 | self.log.info("file dump: {}".format(filename)) 49 | 50 | open(os.path.join(self.path, filename), 'w').writelines(self.record_lines) 51 | 52 | 53 | 54 | 55 | def csv_push(self, dataset): 56 | t = int(datetime.now().timestamp() / self.csv_config['interval']) 57 | 58 | if self.csv_interval_time is None: # first start 59 | self.csv_interval_time = t 60 | elif self.csv_interval_time != t: 61 | self.csv_interval_time = t 62 | # print('minute') 63 | filename = datetime.now().strftime("%Y-%m-%d.csv") 64 | if not os.path.isfile(os.path.join(self.path, filename)): 65 | open(os.path.join(self.path, filename), 'a').write(";".join([col[0] for col in self.csv_config['columns']]) + '\n') 66 | 67 | row = [] 68 | for col in self.csv_config['columns']: 69 | row.append(dictget(dataset, col[1:])) 70 | csv = ";".join(["{}".format('' if r is None else r) for r in row]) 71 | 72 | open(os.path.join(self.path, filename), 'a').write(csv + '\n') 73 | 74 | 75 | 76 | 77 | if __name__ == "__main__": 78 | box = Blackbox(path='blackbox', size=3) 79 | box.push({'t': 1}) 80 | box.push({'t': 2}) 81 | box.push({'t': 3}) 82 | box.push({'t': 4}) 83 | box.dump() 84 | 85 | # config = (('AAA', 'a', 0), ('BBB', 'b'), ('CCC', 'c', 'x')) 86 | # trc = CSVLog(config, interval_divider=5) 87 | # 88 | # while True: 89 | # trc.push({'a': [1, 2, 3], 'b': 'hallo'}) 90 | # 91 | # time.sleep(0.1) 92 | 93 | -------------------------------------------------------------------------------- /bms.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | """ 4 | Interface class description of the BMS interface 5 | """ 6 | 7 | 8 | class BMS(ABC): 9 | 10 | def __init__(self): 11 | self._error = None # None or string in case of an error 12 | self._voltage = None # voltage [V] 13 | self._current = None # current [A] 14 | self._temperature = None # temperature [°C] 15 | self._soc = None # State of charge [%] 16 | self._soc_low = None # lowest soc with multiple packs (set to soc for single pack) 17 | self._soc_high = None # highest soc with multiple packs (set to soc for single pack) 18 | 19 | @property 20 | def voltage(self): 21 | return self._voltage 22 | 23 | @property 24 | def current(self): 25 | return self._current 26 | 27 | @property 28 | def temperature(self): 29 | return self._temperature 30 | 31 | @property 32 | def soc(self): 33 | return self._soc 34 | 35 | @property 36 | def soc_low(self): 37 | return self._soc_low 38 | 39 | @property 40 | def soc_high(self): 41 | return self._soc_high 42 | 43 | @property 44 | def error(self): 45 | return self._error 46 | 47 | @abstractmethod 48 | def update(self): 49 | """ 50 | Run update / read cycle. If implemented as thread, update() must be a Dummy 51 | :return: 52 | """ 53 | pass 54 | 55 | @abstractmethod 56 | def get_state(self): 57 | """ 58 | Get actual state 59 | 60 | :return: Dictionary 61 | """ 62 | pass 63 | 64 | @abstractmethod 65 | def get_detail(self): 66 | """ 67 | Get actual state with detailed Information 68 | 69 | :return: Dictionary 70 | """ 71 | pass 72 | -------------------------------------------------------------------------------- /bms_dummy.py: -------------------------------------------------------------------------------- 1 | from bms import BMS 2 | 3 | 4 | 5 | class BMS_DUMMY(BMS): 6 | def __init__(self, port, timeout): 7 | super().__init__() 8 | # optional (showed in ui but not used in control loop) 9 | self._pack_u = [] 10 | self._pack_i = [] 11 | self._pack_t = [] 12 | self._pack_soc = [] 13 | self._pack_cycle = [] 14 | 15 | def update(self): 16 | # read data from BMS and set values 17 | # self._voltage = ... 18 | pass 19 | 20 | 21 | def get_state(self): 22 | return { 23 | 'u': self._voltage, 24 | 'i': self._current, 25 | 't': self._temperature, 26 | 'soc': self._soc, 27 | 'soc_low': self._soc_low, 28 | 'soc_high': self._soc_high, 29 | 30 | 'pack_u': self._pack_u, 31 | 'pack_i': self._pack_i, 32 | 'pack_t': self._pack_t, 33 | 'pack_soc': self._pack_soc, 34 | 'pack_cycle': self._pack_cycle, 35 | # ... 36 | } 37 | 38 | def get_detail(self): 39 | return { 40 | } 41 | -------------------------------------------------------------------------------- /bms_us2000.py: -------------------------------------------------------------------------------- 1 | from bms import BMS 2 | import logging 3 | import time 4 | from datetime import datetime 5 | from threading import Thread 6 | from pylontech import read_analog_value, read_alarm_info 7 | import serial # pip install pyserial 8 | 9 | 10 | class US2000(BMS): 11 | 12 | def __init__(self, port=None, baudrate=115200, pack_number=1, lifetime=20, log_name='us2000', pause=0.25, type='US2000'): 13 | """ 14 | Service class with polling thread 15 | 16 | :param port: Serial port, '/dev/ttyUSB0' 17 | :param baudrate: Integer, 9600, 115200, ... 18 | :param pack_number: Number of packs 19 | :param lifetime: Time in seconds data is still valid 20 | :param log_name: Name 21 | :param pause: Pause between polling 22 | """ 23 | super().__init__() 24 | self.port = port 25 | self.baudrate = baudrate # save baudrate 26 | self.pack_number = pack_number # number of devices 27 | self.type = type # 'US5000' default 'US2000' or 'US3000' 28 | self.pause = pause 29 | self.lifetime = lifetime 30 | self.log = logging.getLogger(log_name) 31 | self.log.info('init port={}'.format(port)) 32 | self.com = None 33 | 34 | self._pack_u = [None] * self.pack_number 35 | self._pack_i = [None] * self.pack_number 36 | self._pack_t = [None] * self.pack_number 37 | self._pack_soc = [None] * self.pack_number 38 | self._pack_cycle = [None] * self.pack_number 39 | 40 | self.data = {"analog": [None] * pack_number, 41 | "alarm": [None] * pack_number, 42 | "analog_timeout": [time.perf_counter() + self.lifetime] * pack_number, 43 | "alarm_timeout": [time.perf_counter() + self.lifetime] * pack_number, 44 | "frame_count": 0, 45 | "error_analog": [0] * self.pack_number, 46 | "error_alarm": [0] * self.pack_number} 47 | 48 | self.connect() 49 | self.thread = Thread(target=self.run, daemon=True) 50 | self.thread.start() 51 | 52 | def get_state(self): 53 | """ 54 | Get status of BMS as dictionary 55 | """ 56 | return { 57 | 'error': self._error, 58 | 'u': self._voltage, 59 | 'i': self._current, 60 | 't': self._temperature, 61 | 'soc': self._soc, 62 | 'soc_low': self._soc_low, 63 | 'soc_high': self._soc_high, 64 | 65 | 'pack_u': self._pack_u, 66 | 'pack_i': self._pack_i, 67 | 'pack_t': self._pack_t, 68 | 'pack_soc': self._pack_soc, 69 | 'pack_cycle': self._pack_cycle, 70 | } 71 | 72 | def get_detail(self): 73 | """ 74 | Get extra status of BMS as dictionary 75 | """ 76 | return self.data 77 | 78 | def connect(self): 79 | try: 80 | self.com = serial.Serial(self.port, baudrate=self.baudrate, timeout=0.5) # non blocking 81 | except Exception as e: 82 | self.com = None 83 | self.log.error("connect: {}".format(e)) 84 | 85 | def run(self): 86 | while True: 87 | if self.com is None: 88 | self.connect() 89 | else: 90 | self.data['frame_count'] += 1 # count read cycle 91 | 92 | for i in range(self.pack_number): 93 | try: 94 | d = read_analog_value(self.com, i, self.type) 95 | self.log.debug("read_analog_value[{}] {}".format(i, d)) 96 | self.data['analog'][i] = d 97 | self.data['analog'][i]['time'] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 98 | self.data['analog_timeout'][i] = time.perf_counter() + self.lifetime 99 | except IOError: 100 | self.com = None 101 | self.data['analog'][i] = None 102 | self.log.error("read_analog_value: io port failed") 103 | except Exception as e: 104 | self.data['analog'][i] = None 105 | self.data['error_analog'][i] += 1 # count error 106 | self.log.debug("EXCEPTION read_analog_value[{}] {}".format(i, e)) 107 | 108 | time.sleep(self.pause) 109 | 110 | try: 111 | d = read_alarm_info(self.com, i) 112 | self.log.debug("read_alarm_info[{}] {}".format(i, d)) 113 | self.data['alarm'][i] = d 114 | self.data['alarm'][i]['time'] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 115 | self.data['alarm_timeout'][i] = time.perf_counter() + self.lifetime 116 | except IOError: 117 | self.com = None 118 | self.data['alarm'][i] = None 119 | self.log.error("read_alarm_info: io port failed") 120 | except Exception as e: 121 | self.data['alarm'][i] = None 122 | self.data['error_alarm'][i] += 1 # count error 123 | self.log.debug("EXCEPTION read_alarm_info[{}] {}".format(i, e)) 124 | 125 | time.sleep(self.pause) 126 | 127 | self.process_data() 128 | 129 | def process_data(self): 130 | """ 131 | Convert raw bms data to BMS main values (voltage, current, soc, ...) 132 | """ 133 | t = time.perf_counter() 134 | 135 | valid = True 136 | error = None 137 | 138 | for i in range(self.pack_number): # loop over all packs 139 | try: 140 | if self.data['alarm'][i]['error']: # active error 141 | error = 'alarm pack {}'.format(i) 142 | except: 143 | pass 144 | 145 | try: 146 | self._pack_u[i] = self.data['analog'][i]['u'] 147 | self._pack_i[i] = self.data['analog'][i]['i'] 148 | self._pack_t[i] = max(self.data['analog'][i]['t']) 149 | self._pack_soc[i] = self.data['analog'][i]['soc'] 150 | self._pack_cycle[i] = self.data['analog'][i]['cycle'] 151 | except: 152 | valid = False 153 | self._pack_u[i] = None 154 | self._pack_i[i] = None 155 | self._pack_t[i] = None 156 | self._pack_soc[i] = None 157 | self._pack_cycle[i] = None 158 | self.log.exception("process_data with pack {} failed".format(i)) 159 | 160 | if t > self.data['analog_timeout'][i] or t > self.data['alarm_timeout'][i]: 161 | error = "timeout pack {}".format(i) 162 | 163 | # process all packs to main data 164 | 165 | if valid: 166 | try: 167 | self._voltage = max(self._pack_u) 168 | self._current = sum(self._pack_i) 169 | self._temperature = max(self._pack_t) 170 | self._soc = round(sum(self._pack_soc) / self.pack_number) 171 | self._soc_low = round(min(self._pack_soc)) 172 | self._soc_high = round(max(self._pack_soc)) 173 | self._error = None 174 | except: 175 | self._error = "exception" 176 | 177 | if error: 178 | self._error = error 179 | self._voltage = None 180 | self._current = None 181 | self._temperature = None 182 | self._soc = None 183 | self._soc_low = None 184 | self._soc_high = None 185 | 186 | def update(self): 187 | """ 188 | not used because of threaded implementation 189 | """ 190 | pass 191 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | config = { 2 | 'meterhub_address': 'http://192.168.0.10:8008', 3 | 'victron_mk3_port': '/dev/serial/by-id/usb-VictronEnergy_MK3-USB_Interface_HQ2132VK4JK-if00-port0', 4 | 5 | # use 'bms_us2000' for US2000 or US3000 and 'bms_us5000' for US5000 6 | 'bms_us2000': { 7 | 'port': "/dev/serial/by-path/platform-3f980000.usb-usb-0:1.3:1.0", # Raspberry Home (rechts oben) 8 | 'pack_number': 2, # number of Pylontech packs 9 | 'baudrate': 115200, # Baudrate for Pylontech BMS 10 | }, 11 | 12 | # 'bms_seplos': { 13 | # 'port': "", 14 | # ... 15 | # }, 16 | 17 | 'password': '1234', # Password for web access 18 | 19 | 'http_port': 8888, # HTTP port of the webserver 20 | 'www_path': "www", # path for static webserver files 21 | 'log_path': "log", # log path 22 | 23 | # the blackbox stores the specified number of records in /log/blackbox-*.jsonl in case of error 24 | 'blackbox_size': 80, # Number of data sets, storage time: n * 750ms 25 | 26 | 'enable_car': True, # Show car values on dashboard 27 | 'enable_heat': False, # Show heater values on dashboard 28 | 29 | 'udc_max': 54, # maximum voltage (only checked at BMS, MP2 has peaks ?! ToDo !!!) 30 | 't_max': 40, # maximum temperature 31 | 32 | # The setting [0] is used as default, undefined values from other settings [1, ...] are used from the setting [0] 33 | 'setting': [ 34 | { 35 | 'name': 'Standard', 36 | 37 | 'charge_min_power': 300, # [W] lowest charge power 38 | 'charge_max_power': 1000, # [W] highest charge power 39 | 'charge_reserve_power': 200, # [W] "distance" to excess power 40 | 'charge_end_soc': 95, # [%] SOC at which charging is finished 41 | 'charge_hysteresis_soc': 3, # [%] SOC restart hysteresis 42 | 'charge_end_voltage': 52, # [V] Voltage at which the charge is terminated 43 | 'charge_start_time': 30, # [s] Time in which the start condition for the load must exist 44 | 'charge_stop_time': 30, # [s] Delay time in charging mode without charging 45 | 46 | 'feed_min_power': 40, # [W] Minimum feed power 47 | 'feed_max_power': 2000, # [W] Maximum feed power 48 | 'feed_soc25_max_power': 1500, # [W] Maximum feed power below 25% SOC 49 | 'feed_reserve_power': 30, # [W] "Distance" consumption to feed-in power 50 | 'feed_end_soc': 10, # [%] SOC at which feed-in is terminated 51 | 'feed_hysteresis_soc': 5, # [%] SOC restart hysteresis 52 | 'feed_end_voltage': 47.0, # [V] Voltage at which the feed is terminated 53 | 'feed_start_time': 30, # [s] Time in which the start condition for the feed must exist 54 | 'feed_stop_time': 30, # [s] Delay time in feed-in operation without feed-in 55 | 56 | 'feed_throttle_time': 5 * 60, # [s] longer feeds in one piece are limited 57 | 'feed_throttle_power': 1500, # [W] Performance limit with throttling 58 | 59 | 'idle_sleep_time': 10 * 60, # [s] Sleeptimer, (idle --> sleep) Multiplus 60 | }, 61 | { 62 | 'name': 'Maximal-Entladen (Sommer)', 63 | 'feed_max_power': 2400, 64 | 'feed_reserve_power': 10, 65 | 'feed_start_time': 10, 66 | 'feed_stop_time': 5, 67 | }, 68 | { 69 | 'name': 'Maximal-Laden (Winter)', 70 | 'charge_min_power': 200, 71 | 'charge_max_power': 1600, 72 | 'charge_reserve_power': 25, 73 | 'charge_start_time': 10, 74 | 'charge_stop_time': 5, 75 | 'feed_soc25_max_power': 750, 76 | }, 77 | { 78 | 'name': 'Nur Laden', 79 | 'charge_min_power': 200, 80 | 'charge_max_power': 1600, 81 | 'charge_reserve_power': 25, 82 | 'charge_start_time': 10, 83 | 'charge_stop_time': 5, 84 | 'feed_end_soc': 150, # never start feeding 85 | }, 86 | { 87 | 'name': 'Max-Laden / 20% Reserve', 88 | 'charge_min_power': 200, 89 | 'charge_max_power': 1600, 90 | 'charge_reserve_power': 25, 91 | 'charge_start_time': 10, 92 | 'charge_stop_time': 5, 93 | 'feed_soc25_max_power': 750, 94 | 'feed_max_power': 1000, # [W] Maximum feed power 95 | 'feed_reserve_power': 50, # [W] "Distance" consumption to feed-in power 96 | 'feed_end_soc': 20, # [%] SOC at which feed-in is terminated 97 | 'feed_hysteresis_soc': 10, # [%] SOC restart hysteresis 98 | }, 99 | { 100 | 'name': 'Full 100% Charge', 101 | 'charge_min_power': 200, 102 | 'charge_max_power': 500, 103 | 'charge_reserve_power': 50, 104 | 'charge_end_voltage': 53.75, # [V] Voltage at which the charge is terminated 105 | 'charge_start_time': 10, 106 | 'charge_stop_time': 5, 107 | 'charge_end_soc': 105, # [%] SOC at which charging is finished 108 | 'charge_hysteresis_soc': 2, # [%] SOC restart hysteresis 109 | 'feed_soc25_max_power': 750, 110 | 'feed_max_power': 1000, # [W] Maximum feed power 111 | 'feed_reserve_power': 50, # [W] "Distance" consumption to feed-in power 112 | 'feed_end_soc': 20, # [%] SOC at which feed-in is terminated 113 | 'feed_hysteresis_soc': 10, # [%] SOC restart hysteresis 114 | } 115 | 116 | ], 117 | 118 | # enable csv log 119 | 'csv_log': {'interval': 60, # storage interval in seconds 120 | 'columns': [ # first entry is the name, second and so on the route inside main dataset /api/state 121 | ('time', 'ess', 'time'), 122 | ('state', 'ess', 'state'), 123 | ('bat_ac_p', 'meterhub', 'bat_p'), 124 | ('mp2_bat_u', 'multiplus', 'bat_u'), 125 | ('mp2_bat_i', 'multiplus', 'bat_i'), 126 | ('bms_u0', 'bms', 'pack_u', 0), 127 | ('bms_u1', 'bms', 'pack_u', 1), 128 | ('bms_i0', 'bms', 'pack_i', 0), 129 | ('bms_i1', 'bms', 'pack_i', 1), 130 | ('bms_soc0', 'bms', 'pack_soc', 0), 131 | ('bms_soc1', 'bms', 'pack_soc', 1)]}, 132 | 133 | } 134 | -------------------------------------------------------------------------------- /demo_multiplus2.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | from multiplus2 import MultiPlus2 4 | 5 | logging.basicConfig( 6 | level=logging.INFO, 7 | format='%(asctime)s.%(msecs)03d %(name)-10s %(levelname)-6s %(message)s', 8 | datefmt='%Y-%m-%d %H:%M:%S', 9 | ) 10 | 11 | # port = '/dev/ttyUSB1' 12 | port = '/dev/serial/by-id/usb-VictronEnergy_MK3-USB_Interface_HQ2132VK4JK-if00-port0' 13 | 14 | logging.getLogger('vebus').setLevel(logging.DEBUG) 15 | 16 | mp2 = MultiPlus2(port) 17 | while True: 18 | t0 = time.perf_counter() 19 | mp2.update() # read all information 20 | print(time.perf_counter() - t0, mp2.data) 21 | time.sleep(0.5) 22 | # mp2.vebus.set_power(-200) # set feed power to 200Watt 23 | # mp2.vebus.set_power(500) # set charge power to 500Watt 24 | time.sleep(1) -------------------------------------------------------------------------------- /demo_pylontech.py: -------------------------------------------------------------------------------- 1 | import time 2 | import serial 3 | from pylontech import * 4 | 5 | # port = '/dev/ttyACM0' 6 | port = "/dev/serial/by-path/platform-3f980000.usb-usb-0:1.3:1.0" # Raspberry Home (rechts oben) 7 | 8 | pack_number = 2 9 | 10 | com = serial.Serial(port, baudrate=115200, timeout=0.5) # non blocking 11 | 12 | # === serial_number === 13 | 14 | for i in range(pack_number): 15 | try: 16 | d = read_serial_number(com, i) 17 | print(d) 18 | except Exception as e: 19 | print("Exception", e) 20 | time.sleep(1) 21 | 22 | # === manufacturer_info === ACHTUNG !!! im Verbund nicht zuverlässig !!! 23 | 24 | for i in range(pack_number): 25 | try: 26 | d = read_manufacturer_info(com, i) 27 | print(d) 28 | except Exception as e: 29 | print("Exception", e) 30 | time.sleep(1) 31 | 32 | while True: 33 | 34 | # === analog_value === 35 | 36 | for i in range(pack_number): 37 | try: 38 | d = read_analog_value(com, i) 39 | print(i, d) 40 | except Exception as e: 41 | print("Exception", e) 42 | 43 | time.sleep(2) 44 | 45 | # === alarm_info === 46 | 47 | for i in range(pack_number): 48 | try: 49 | d = read_alarm_info(com, i) 50 | print(i, d) 51 | except Exception as e: 52 | print("Exception", e) 53 | 54 | time.sleep(2) 55 | -------------------------------------------------------------------------------- /demo_us2000.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | from bms_us2000 import US2000 4 | 5 | logging.basicConfig( 6 | level=logging.INFO, # DEBUG INFO 7 | format='%(asctime)s %(name)-10s %(levelname)-6s %(message)s', 8 | datefmt='%Y-%m-%d %H:%M:%S', 9 | ) 10 | 11 | # port = '/dev/ttyACM0' 12 | port = "/dev/serial/by-path/platform-3f980000.usb-usb-0:1.3:1.0" # Raspberry Home (rechts oben) 13 | 14 | us2000 = US2000(port, pack_number=2) 15 | while True: 16 | print(us2000.get_state()) 17 | time.sleep(1) 18 | -------------------------------------------------------------------------------- /development/api_state_idle.json: -------------------------------------------------------------------------------- 1 | { 2 | "ess": { 3 | "mode": "auto", 4 | "state": "auto_idle", 5 | "set_p": 0, 6 | "time": "2022-12-04 23:06:37", 7 | "setting": 1, 8 | "info": "Automatik - Bereit" 9 | }, 10 | "meterhub": { 11 | "time": "2022-12-04 23:06:35", 12 | "timestamp": 1670187995, 13 | "grid_imp_eto": 4800697, 14 | "grid_exp_eto": 31296778, 15 | "grid_p": 526, 16 | "pv1_eto": 24902000, 17 | "pv2_eto": 16554420, 18 | "pv1_e_day": 513, 19 | "pv2_e_day": 0, 20 | "pv1_p": 0, 21 | "pv2_p": 0, 22 | "pv_p": 0, 23 | "home_all_eto": 16385020, 24 | "home_all_p": 504, 25 | "home_p": 504, 26 | "flat_eto": 67189, 27 | "flat_p": 0, 28 | "bat_imp_eto": 2171082, 29 | "bat_exp_eto": 1143410, 30 | "bat_p": 5, 31 | "car_eto": 2936746, 32 | "car_p": 0, 33 | "car_e_cycle": 0, 34 | "car_amp": 6, 35 | "car_phase": 1, 36 | "car_stop": true, 37 | "car_state": "wait", 38 | "water_vto": 1402473, 39 | "car_pv_ready": false, 40 | "car_plug": true, 41 | "car_info": "PV - Laden gesperrt", 42 | "bat_info": "Automatik - Bereit", 43 | "bat_soc": 10, 44 | "measure_time": 0.725 45 | }, 46 | "bms": { 47 | "u": 47.887, 48 | "soc": 10, 49 | "t": 18.4, 50 | "ready": true, 51 | "u_pack": [ 52 | 47.866, 53 | 47.887 54 | ], 55 | "i_pack": [ 56 | 0.0, 57 | 0.0 58 | ], 59 | "soc_pack": [ 60 | 10, 61 | 11 62 | ], 63 | "cycle_pack": [ 64 | 133, 65 | 377 66 | ], 67 | "t_pack": [ 68 | 18.4, 69 | 18.0 70 | ] 71 | }, 72 | "multiplus": { 73 | "device_state_id": 8, 74 | "device_state_name": "bypass", 75 | "mains_u": 229.8, 76 | "mains_i": 0.48, 77 | "inv_u": 229.8, 78 | "inv_i": 0.29, 79 | "inv_p": 2, 80 | "out_p": -1, 81 | "bat_u": 47.94, 82 | "bat_i": 0.0, 83 | "bat_p": 0, 84 | "led_light": 1, 85 | "led_blink": 0, 86 | "led_info": [ 87 | "mains" 88 | ], 89 | "state": "on" 90 | } 91 | } -------------------------------------------------------------------------------- /development/info.txt: -------------------------------------------------------------------------------- 1 | keine Steuerung / 0W 2 | 3 | mains on | bulk | inverter on | BUS 4 | 0 off 5 | X 9 inverting startup ohne Steuerung, nach Zeit ... 6 | X 8 passthru keine Steuerung 7 | X X X 3 bulk 0W 8 | X X 3 bulk laden 9 | blink X X 3 bulk entladen (-) 10 | 11 | Battery, Grid, Setpoint 12 | laden + 13 | einspeisen - 14 | 15 | ========================================================== 16 | 17 | Nur mains on: DC: 4,6W AC: 4,4W 18 | ohne AC: DC: 13,2W AC: --- 19 | OFF via BUS: DC: 1,3W AC: --- 20 | 21 | ========================================================== 22 | Remote Eingang 23 | 24 | 13,9V 25 | Strom mit Brücke: 540uA 26 | Funktion ab 2k2 27 | 28 | ========================================================== 29 | 30 | Spannungsabfall @2400Watt auf Zuleitung: 130mV (Minus) 180mV (Plus mit Sicherung) = 310mV 31 | 32 | Differenz zwischen BMS der Batterie und Multiplus 470mV 33 | 34 | 35 | https://www.victronenergy.com/live/battery_compatibility:pylontech_phantom -------------------------------------------------------------------------------- /development/wirkungsgrad.ods: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/development/wirkungsgrad.ods -------------------------------------------------------------------------------- /doc/ess_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/doc/ess_config.png -------------------------------------------------------------------------------- /doc/ess_hw_block.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/doc/ess_hw_block.png -------------------------------------------------------------------------------- /doc/ess_sw_block.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/doc/ess_sw_block.png -------------------------------------------------------------------------------- /doc/ess_trace_feed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/doc/ess_trace_feed.png -------------------------------------------------------------------------------- /doc/ess_web_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/doc/ess_web_app.png -------------------------------------------------------------------------------- /ess.py: -------------------------------------------------------------------------------- 1 | """ 2 | ESS Starter 3 | """ 4 | 5 | import logging 6 | import os 7 | from logging.handlers import TimedRotatingFileHandler 8 | from app import App 9 | 10 | os.makedirs('log', exist_ok=True) # create paths if necessary 11 | 12 | 13 | logging.basicConfig(level=logging.INFO, 14 | format="%(asctime)s %(name)-12s %(levelname)-8s %(message)s", 15 | datefmt='%Y-%m-%d %H:%M:%S', 16 | handlers=[TimedRotatingFileHandler('log/log.txt', when='midnight'), 17 | logging.StreamHandler()]) 18 | 19 | # specific logger configuration 20 | # logging.getLogger('meterhub').setLevel(logging.DEBUG) # enable debug logging for a specific module 21 | 22 | logging.getLogger('vebus').setLevel(logging.ERROR) 23 | logging.getLogger('bms').setLevel(logging.ERROR) 24 | # logging.getLogger('bms').setLevel(logging.DEBUG) 25 | # logging.getLogger('bms').setLevel(logging.CRITICAL) 26 | logging.getLogger('meterhub').setLevel(logging.ERROR) 27 | 28 | app = App() 29 | app.start() # start application mainloop 30 | -------------------------------------------------------------------------------- /ess.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ESS 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/usr/bin/python3 -u ess.py 7 | WorkingDirectory=/home/pi/ess 8 | StandardOutput=inherit 9 | StandardError=inherit 10 | Restart=no 11 | User=pi 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /fsm.py: -------------------------------------------------------------------------------- 1 | # 13.10.2022 Martin Steppuhn 2 | 3 | class FSM: 4 | """ 5 | Minimal FSM Framework. Could be used as baseclass to provide statemachine functionality. 6 | Init must be called with: super().__init__('****') 7 | 8 | set_fsm_state(state) 9 | run_fsm() 10 | """ 11 | 12 | def __init__(self, state): 13 | """ 14 | Init 15 | """ 16 | self._fsm_state = state # actual state, string with suffix 'state1' --> self.fsm_state1 17 | self._fsm_next_state = state # next state, string with suffix 'state1' --> self.fsm_state1 18 | 19 | def run_fsm(self): 20 | """ 21 | Run statemachine 22 | """ 23 | if self._fsm_next_state: 24 | entry = True # set entry flag for first run in new state 25 | self._fsm_state = self._fsm_next_state 26 | self._fsm_next_state = None 27 | else: 28 | entry = False 29 | 30 | try: 31 | getattr(self, "fsm_" + self._fsm_state)(entry) 32 | except: 33 | self.log.exception("fsm call exception: {}".format(self._fsm_state)) 34 | 35 | def set_fsm_state(self, state): 36 | """ 37 | Switch state 38 | 39 | :param state: string with suffix 'state1' --> self.fsm_state1 40 | """ 41 | self._fsm_next_state = state 42 | -------------------------------------------------------------------------------- /icon/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/icon/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /icon/ess-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 41 | image/svg+xml 45 | 47 | 54 | 55 | 56 | 63 | -------------------------------------------------------------------------------- /icon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/icon/favicon.ico -------------------------------------------------------------------------------- /icon/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/icon/favicon.png -------------------------------------------------------------------------------- /multiplus2.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | 4 | from vebus import VEBus 5 | 6 | """ 7 | Multiplus-II, ESS Mode 3 8 | 9 | 22.01.2023 Martin Steppuhn 10 | """ 11 | 12 | 13 | class MultiPlus2: 14 | def __init__(self, port, timeout=10): 15 | self.vebus = VEBus(port=port, log='vebus') 16 | self.log = logging.getLogger('mp2') 17 | self.timeout = timeout 18 | 19 | self.data_timeout = time.perf_counter() + self.timeout 20 | self.data = None # Dictionary with all information from Multiplus 21 | 22 | self.online = False # True if connection is established 23 | 24 | self.cmd_lock_time = None # sleep / wakeup "timer" (time.perf_counter()) 25 | self.power_delay_time = time.perf_counter() # set 0 Watt for a time before disable sendening power command 26 | 27 | self._wakeup = False 28 | self._sleep = False 29 | 30 | def sleep(self): 31 | self._sleep = True 32 | 33 | def wakeup(self): 34 | self._wakeup = True 35 | 36 | def connect(self): 37 | version = self.vebus.get_version() # hide errors while scanning 38 | if version: 39 | self.data = {'mk2_version': version} # init dictionary 40 | time.sleep(0.1) 41 | if self.vebus.init_address(): 42 | time.sleep(0.1) 43 | if self.vebus.scan_ess_assistant(): 44 | self.log.info("ess assistant setpoint ramid={}".format(self.vebus.ess_setpoint_ram_id)) 45 | self.data['state'] = 'init' 46 | self.online = True 47 | self.data_timeout = time.perf_counter() + self.timeout # start timeout 48 | 49 | 50 | 51 | def command(self, power): 52 | if self.online: 53 | t = time.perf_counter() 54 | if self._wakeup and not self.cmd_lock_time: 55 | self.cmd_lock_time = t + 3 # lock command for 3 seconds 56 | self._wakeup = False 57 | self.vebus.wakeup() 58 | self.log.info("wakeup") 59 | elif self._sleep and not self.cmd_lock_time: 60 | self.cmd_lock_time = t + 3 # lock command for 3 seconds 61 | self._sleep = False 62 | self.vebus.sleep() 63 | self.log.info("sleep") 64 | else: 65 | if abs(power) >= 1: 66 | if self.power_delay_time is None: 67 | self.log.info("set_power start {}".format(power)) 68 | self.log.debug("set_power {}".format(power)) 69 | self.vebus.set_power(power) # send command to multiplus 70 | self.power_delay_time = t + 5 # send zero for 5seconds after last value >= 1 71 | elif self.power_delay_time: 72 | self.vebus.set_power(0) 73 | if t > self.power_delay_time: 74 | self.power_delay_time = None 75 | self.log.debug("set_power zero trailing timer end") 76 | 77 | # reset command lock timer 78 | if self.cmd_lock_time and t > self.cmd_lock_time: 79 | self.cmd_lock_time = None 80 | 81 | def update(self, pause_time=0.1): 82 | """ 83 | Read all information from Multiplus-II 84 | 85 | :param pause_time: pause time between commands 86 | :return: dictionary 87 | """ 88 | if not self.online: 89 | self.connect() 90 | 91 | else: 92 | self.vebus.send_snapshot_request() # trigger snapshot 93 | time.sleep(pause_time) 94 | part1 = self.vebus.get_ac_info() # read ac infos and append to data dictionary 95 | time.sleep(pause_time) 96 | if part1: 97 | part2 = self.vebus.read_snapshot() # read snapshot infos and append to data dictionary 98 | time.sleep(pause_time) 99 | if part2: 100 | part3 = self.vebus.get_led() # read led infos and append to data dictionary 101 | if part3: 102 | data = {} 103 | data.update(part1) 104 | data.update(part2) 105 | data.update(part3) 106 | led = data.get('led_light', 0) + data.get('led_blink', 0) 107 | state = data.get('device_state_id', None) 108 | if state == 2: 109 | data['state'] = 'sleep' 110 | elif led & 0x40: 111 | data['state'] = 'low_bat' 112 | elif led & 0x80: 113 | data['state'] = 'temperature' 114 | elif led & 0x20: 115 | data['state'] = 'overload' 116 | elif state == 8 or state == 9: 117 | data['state'] = 'on' 118 | elif state == 4: 119 | data['state'] = 'wait' 120 | else: 121 | data['state'] = '?{}?0x{:02X}?'.format(state, led) 122 | 123 | self.data = data 124 | self.data_timeout = time.perf_counter() + self.timeout # reset data timeout with valid rx 125 | 126 | if time.perf_counter() > self.data_timeout: 127 | self.online = False 128 | self.data = {'error': 'offline', 'state': 'offline'} 129 | -------------------------------------------------------------------------------- /pylontech.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from config import config 3 | 4 | """ 5 | Pylontech / US2000 6 | 7 | Packet and Framehandling. See demo_pylontech.py for a simple example. 8 | 9 | 30.11.2022 Martin Steppuhn Split in pylontech.py (basic packets) and us2000.py (threaded service class) 10 | 31.12.2022 Martin Steppuhn US3000 Quickhack 11 | """ 12 | 13 | 14 | def read_analog_value(com, address, type='US2000'): 15 | """ 16 | Read analog value (MAIN INFORMATION) 17 | 18 | TX: b'~20024642E00202FD33\r' 19 | RX: b'~20024600C06E10020F0C9A0C980C990C980C9A0C9A0C990C9B0C9C0C9A0C9B0C9B0C9B0C9B0C99050B740B550B570B530B630000BD06190F02C3500084E545\r' 20 | 21 | {'u_cell': (3226, 3224, 3225, 3224, 3226, 3226, 3225, 3227, 3228, 3226, 3227, 3227, 3227, 3227, 3225), 't': [20.1, 17.0, 17.2, 16.8, 18.4], 'q': 6415, 'q_total': 50000, 'cycle': 132, 'i': 0.0, 'u': 48.39, 'soc': 13} 22 | 23 | US3000 24 | TX: b'~20024642E00202FD33\r' 25 | RX: b'~20024600F07A00020F0CC90CC90CC80CC90CC80CC80CC80CC90CC90CC90CC80CC90CC90CC80CC8050BA10B8A0B870B840B910000BFC0FFFF04FFFF0000007968012110E211\r' 26 | 27 | 28 | :param com: PySerial 29 | :param address: Address 0, ... 30 | :return: Dictionary with values 31 | """ 32 | tx = encode_cmd(address + 2, 0x42, "{:02X}".format(address + 2).encode('utf-8')) 33 | # print("TX:", tx) 34 | com.reset_input_buffer() 35 | com.write(tx) 36 | 37 | rx = com.read_until(b'\r') 38 | # print("RX:", rx) 39 | frame = decode_frame(rx) 40 | if frame is not None: 41 | return parse_analog_value(frame, type) 42 | else: 43 | raise ValueError('receive failed dump={}'.format(rx)) 44 | 45 | 46 | def parse_analog_value(frame, type): 47 | """ 48 | Parser for analog value packet 49 | 50 | US2000 RX: b'~20024600C06E10020F0C9A0C980C990C980C9A0C9A0C990C9B0C9C0C9A0C9B0C9B0C9B0C9B0C99050B740B550B570B530B630000BD06190F02C3500084E545\r' 51 | US3000 RX: b'~20024600F07A00020F0CC90CC90CC80CC90CC80CC80CC80CC90CC90CC90CC80CC90CC90CC80CC8050BA10B8A0B870B840B910000BFC0FFFF04FFFF0000007968012110E211\r' 52 | 53 | US2000 20 02 46 00 C0 6E 10 02 54 | 0F 55 | 0C9A 0C98 0C99 0C98 0C9A 0C9A 0C99 0C9B 0C9C 0C9A 0C9B 0C9B 0C9B 0C9B 0C99 56 | 05 57 | 0B74 0B55 0B57 0B53 0B63 58 | 0000 BD06 190F 02 C350 0084 current, voltage, q, ?, q_total, cycle 59 | E545 Checksum 60 | 61 | US3000 20 02 46 00 F0 7A 00 02 62 | 0F 63 | 0CC9 0CC9 0CC8 0CC9 0CC8 0CC8 0CC8 0CC9 0CC9 0CC9 0CC8 0CC9 0CC9 0CC8 0CC8 64 | 05 65 | 0BA1 0B8A 0B87 0B84 0B91 66 | 0000 BFC0 FFFF 04 FFFF 0000 67 | 007968 012110 68 | E211 Checksum 69 | 70 | 71 | :param frame: bytes 72 | :return: dictionary 73 | """ 74 | 75 | if type == 'US5000': 76 | offs = 44 77 | else: 78 | offs = 42 # US2000 / US3000 79 | 80 | d = {} 81 | p = 8 82 | cell_number = frame[p] # US2000 = 15 Zellen 83 | d['u_cell'] = struct.unpack_from(">HHHHHHHHHHHHHHH", frame, p + 1) 84 | temp_number = frame[p + 31] 85 | temp = struct.unpack_from(">HHHHH", frame, p + 32) 86 | d['t'] = [(t - 2731) / 10 for t in temp] 87 | # Ampere, positive (charge), negative (discharge), with 100mA steps 88 | current, voltage, q1, id, q1_total, d['cycle'] = struct.unpack_from(">hHHbHH", frame, p + offs) 89 | d['i'] = current / 10 90 | d['u'] = voltage / 1000 91 | 92 | if id == 4: # US3000 and US5000 93 | p = p + offs + 11 94 | d['q'] = struct.unpack(">I", b'\x00' + frame[p:p+3])[0] 95 | p += 3 96 | d['q_total'] = struct.unpack(">I", b'\x00' + frame[p:p + 3])[0] 97 | else: 98 | d['q'] = q1 99 | d['q_total'] = q1_total 100 | 101 | d['soc'] = round(100 * d['q'] / d['q_total']) 102 | return d 103 | 104 | 105 | def read_serial_number(com, address): 106 | """ 107 | Read serialnumber 108 | 109 | TX: b'~20024693E00202FD2D\r' 110 | RX: b'~20024600C0220248505443523033313731313132353930F6D2\r' {'serial': 'HPTCR03171112590'} 111 | 112 | TX: b'~20034693E00203FD2B\r' 113 | RX: b'~20034600C0220348505442483032323430413031323335F6D5\r' {'serial': 'HPTBH02240A01235'} 114 | 115 | :param com: PySerial 116 | :param address: Address 0, ... 117 | :return: Dictionary with serial 118 | """ 119 | tx = encode_cmd(address + 2, 0x93, "{:02X}".format(address + 2).encode('utf-8')) 120 | # print(tx) 121 | com.write(tx) # send command to battery 122 | com.reset_input_buffer() # clear receive buffer 123 | rx = com.read_until(b'\r') # read 124 | # print(rx) 125 | frame = decode_frame(rx) # check if valid frame 126 | if frame is not None: 127 | return {'serial': frame[7:7 + 16].rstrip(b'\x00').decode("utf-8")} 128 | else: 129 | raise ValueError('receive failed dump={}'.format(rx)) 130 | 131 | 132 | def read_alarm_info(com, address): 133 | """ 134 | Read alarm status from a pack 135 | 136 | Pack 0: 137 | TX: b'~20024644E00202FD31\r' 138 | RX: b'~20024600A04210020F000000000000000000000000000000050000000000000000000E00000000F108\r' 139 | 0 {'u_cell': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 't': [0, 0, 0, 0, 0], 'i_chg': 0, 'u_pack': 0, 'i_dis': 0, 'status': [0, 14, 0, 0, 0], 'ready': True} 140 | 141 | Pack 1: 142 | TX: b'~20034644E00203FD2F\r' 143 | RX: b'~20034600A04210030F000000000000000000000000000000050000000000000000000E00000000F106\r' 144 | 1 {'u_cell': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 't': [0, 0, 0, 0, 0], 'i_chg': 0, 'u_pack': 0, 'i_dis': 0, 'status': [0, 14, 0, 0, 0], 'ready': True} 145 | 146 | :param com: PySerial 147 | :param address: Address 0, ... 148 | :return: Dictionary with values 149 | """ 150 | tx = encode_cmd(address + 2, 0x44, "{:02X}".format(address + 2).encode('utf-8')) 151 | # print("TX:", tx) 152 | com.reset_input_buffer() 153 | com.write(tx) 154 | 155 | rx = com.read_until(b'\r') 156 | # print("RX:", rx) 157 | frame = decode_frame(rx) 158 | if frame is not None: 159 | return parse_alarm_info(frame) 160 | else: 161 | raise ValueError('receive failed dump={}'.format(rx)) 162 | 163 | 164 | def parse_alarm_info(frame): 165 | """ 166 | Parser for alarm info packet 167 | 168 | :param frame: bytes 169 | :return: dictionary, 'ready'=True --> READY, if 'error' is set --> FAILURE 170 | """ 171 | d = {} 172 | p = 8 173 | cell_number = frame[p] # US2000 = 15 Zellen 174 | d['u_cell'] = list(frame[p + 1:p + 1 + cell_number]) # bytes to list 175 | p = p + 1 + cell_number 176 | temp_number = frame[p] 177 | d['t'] = list(frame[p + 1:p + 1 + temp_number]) 178 | p = p + 1 + temp_number 179 | d['i_chg'], d['u_pack'], d['i_dis'] = struct.unpack_from(">BBB", frame, p) 180 | p += 3 181 | d['status'] = list(frame[p:p + 5]) # bytes to list 182 | d['ready'] = True if (d['status'][1] & 0x04) else False 183 | if d['status'][0]: 184 | d['error'] = True 185 | return d 186 | 187 | 188 | def read_manufacturer_info(com, address): 189 | """ 190 | Get manufacturer information 191 | 192 | ACHTUNG!!! 193 | 194 | Bei der Abfrage von zwei verschiedenen Akkus über die Adresse 2 und 3 wird immer die selbe Information geliefert !!! 195 | 196 | TX: b'~200246510000FDAC\r' 197 | RX: b'~20024600C04055533230303043000000010750796C6F6E2D2D2D2D2D2D2D2D2D2D2D2D2D2D2DEFBD\r' 198 | {'device': 'US2000C', 'version': '1.7', 'manufacturer': 'Pylon'} 199 | 200 | TX: b'~200346510000FDAB\r' 201 | RX: b'~20034600C04055533230303043000000010750796C6F6E2D2D2D2D2D2D2D2D2D2D2D2D2D2D2DEFBC\r' 202 | {'device': 'US2000C', 'version': '1.7', 'manufacturer': 'Pylon'} 203 | 204 | 205 | Bei einer Einzelabfrage des zweiten System kommt die richtige Information !!! 206 | 207 | TX: b'~200246510000FDAC\r' 208 | RX: b'~20024600C0405553324B42504C000000020450796C6F6E2D2D2D2D2D2D2D2D2D2D2D2D2D2D2DEF97\r' 209 | {'device': 'US2KBPL', 'version': '2.4', 'manufacturer': 'Pylon'} 210 | 211 | ===> Im Verbudn über den Link nicht zu gebrauchen 212 | """ 213 | 214 | tx = encode_cmd(address + 2, 0x51) 215 | print('TX:', tx) 216 | com.write(tx) # send command to battery 217 | com.reset_input_buffer() # clear receive buffer 218 | rx = com.read_until(b'\r') # read 219 | print('RX:', rx) 220 | rx_frame = decode_frame(rx) # check if valid frame 221 | if rx_frame is not None: 222 | d = {} 223 | d['device'] = rx_frame[6:16].rstrip(b'\x00').decode("utf-8") 224 | d['version'] = "{}.{}".format(rx_frame[16], rx_frame[17]) 225 | d['manufacturer'] = rx_frame[18:38].rstrip(b'-').decode("utf-8") 226 | return d 227 | else: 228 | raise ValueError('receive failed dump={}'.format(rx)) 229 | 230 | 231 | # ====== Helpers ====== 232 | 233 | def get_frame_checksum(frame): 234 | """ 235 | Calculate checksum for a given frame 236 | 237 | :param frame: ascii hex frame 238 | :return: checksum as interger 239 | """ 240 | checksum = 0 241 | for b in frame: 242 | checksum += b 243 | checksum = ~checksum 244 | checksum %= 0x10000 245 | checksum += 1 246 | return checksum 247 | 248 | 249 | def get_info_length(info): 250 | """ 251 | Build length code for information field 252 | 253 | :param info: information field 254 | :return: length code 255 | """ 256 | lenid = len(info) # length 257 | if lenid == 0: 258 | return 0 259 | li = (lenid & 0xf) + ((lenid >> 4) & 0xf) + ((lenid >> 8) & 0xf) 260 | li = li % 16 261 | li = 0b1111 - li + 1 262 | return (li << 12) + lenid 263 | 264 | 265 | def encode_cmd(addr, cid2, info=b''): 266 | """ 267 | Encode command frame 268 | 269 | Example: b'\x7E20024642E00202FD33\x0D' \x7E 20 02 46 42 E0 02 02 FD 33 \x0D 270 | 20 Version 271 | 02 Address 272 | 46 CID1 273 | 42 CID2 274 | E0 02 Length 275 | 02 Info (Command info), Address to read, RS485 Address starts at 2 276 | FD 33 Checksum 277 | 278 | :param addr: address 279 | :param cid2: cid2 code 280 | :param info: additional parameter (called information) 281 | :return: frame 282 | """ 283 | cid1 = 0x46 284 | info_length = get_info_length(info) 285 | inner_frame = "{:02X}{:02X}{:02X}{:02X}{:04X}".format(0x20, addr, cid1, cid2, info_length).encode() 286 | inner_frame += info 287 | checksum = get_frame_checksum(inner_frame) 288 | frame = (b"~" + inner_frame + "{:04X}".format(checksum).encode() + b"\r") 289 | 290 | return frame 291 | 292 | 293 | def decode_frame(raw_frame): 294 | """ 295 | Decode received frame, checksum is validated 296 | 297 | :param raw_frame: Raw ASCII Hex frame 298 | :return: frame in bytes 299 | """ 300 | if len(raw_frame) >= 18 and raw_frame[0] == ord('~') and raw_frame[-1] == ord('\r') and len(raw_frame) % 2 == 0: 301 | frame_chk = int(raw_frame[-5:-1], 16) # hex encoded checksum from received frame 302 | calc_chk = get_frame_checksum(raw_frame[1:-5]) # calculated checksum from data 303 | # print(frame_chk, calc_chk) 304 | if frame_chk == calc_chk: 305 | frame = bytes.fromhex(raw_frame[1:-1].decode("utf-8")) # hex --> bytes 306 | return frame 307 | return None 308 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Energy Storage System 2 | 3 | Die ESS Software kann einen Victron Multiplus-II im netzparallelen Betrieb direkt ansteuern (**ohne** Venus GX, **ohne** Venus OS). 4 | Die PV Erzeugung und der Verbrauch des Gebäudes wird kontinuierlich erfasst. Bei Überschuss wird der Akku geladen. 5 | Die im Akku gespeicherte Energie wird über Nulleinspeisung abgegeben (Der Inverter erzeugt genau soviel Energie wie benötigt wird) 6 | 7 | Features: 8 | * Automatische Lade-/Entladesteuerung 9 | * Nulleinspeisungsregelung 10 | * Integrierter Webserver mit Web-App 11 | * Multiplus-II Sleepmode (1,3Watt) 12 | * Integration des Pylontech US2000 BMS 13 | * Konfiguration eigener Profile 14 | * API (HTTP/JSON) 15 | * Benötigt lediglich Python und USB-Ports (Raspberry, Synology NAS, ...) 16 | 17 | Die reine Ansteuerung des Multiplus 2 im ESS Mode 3, ohne Weboberfläche etc. ist hier zu finden: https://github.com/martiby/multiplus2 18 | 19 | ## Webserver / Web-App 20 | 21 | ![](doc/ess_web_app.png) 22 | 23 | ## Hardware 24 | 25 | * Victron Multiplus-II 26 | * Victron MK3-USB-Interface 27 | * Pylontech US2000 28 | * Stromzähler: Eastron SDM630 29 | * 2x Waveshare USB TO RS485 30 | 31 | ![](doc/ess_hw_block.png) 32 | 33 | ## Software 34 | 35 | ![](doc/ess_sw_block.png) 36 | 37 | ## Live-Ansicht der Regelung 38 | 39 | Die Regelung (Zykluszeit 750ms) wird mit den einzelnen Messungen visualisiert. 40 | 41 | ![](doc/ess_trace_feed.png) 42 | 43 | ## Konfiguartion 44 | 45 | Über die Datei `config.py` können Einstellungen vorgenommen werden. Es könne individuelle Profile erstellt werden, die 46 | über den Webserver einfach umgeschaltet werden können. 47 | 48 | ![](doc/ess_config.png) 49 | 50 | 51 | ## Installation 52 | 53 | Die Library Chart.js `/www/lib/chart.js` ist nicht Bestandteil des Repositories. In der Releaseversion ist sie enthalten. 54 | 55 | **Python** 56 | 57 | pip3 install -r requirements.txt 58 | 59 | **Service einrichten** 60 | 61 | sudo cp ess.service /etc/systemd/system 62 | 63 | **Service verwenden** 64 | 65 | sudo systemctl start ess 66 | sudo systemctl stop ess 67 | sudo systemctl restart ess 68 | sudo systemctl enable ess 69 | sudo systemctl disable ess 70 | 71 | **Logging abfragen** 72 | 73 | sudo journalctl -u ess 74 | sudo journalctl -u ess -f -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyserial 2 | bottle 3 | waitress 4 | requests -------------------------------------------------------------------------------- /resources/ban.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/bolt-lightning.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/bolt.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/car-battery.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/car.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/check.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/chevron-left.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/chevron-right.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/circle-minus.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/circle-xmark.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/clock1.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/clock2.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/cloud.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/gear.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/house.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/industry.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /resources/sun.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /session.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from bottle import request, response 4 | 5 | 6 | class Session: 7 | """ 8 | Simple sessionmanager for Bottle Webserver 9 | """ 10 | def __init__(self, password, id_length=20): 11 | self.id_length = 20 12 | self.password = password 13 | self.sessions = [] 14 | 15 | def login(self, password): 16 | if password == self.password: 17 | session_id = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(self.id_length)) 18 | if session_id not in self.sessions: 19 | self.sessions.append(session_id) 20 | self.sessions = self.sessions[-25:] # limit to the latest 25 logins 21 | response.set_cookie('session', session_id, max_age=31556952 * 2) 22 | return True 23 | else: 24 | return False 25 | 26 | def is_valid(self, session_id): 27 | if session_id is None: 28 | # print("no session cookie set") 29 | return False 30 | elif session_id in self.sessions: 31 | # print("session is valid: {}".format(session_id)) 32 | return True 33 | else: 34 | # print("invalid session: {}".format(session_id)) 35 | return False 36 | -------------------------------------------------------------------------------- /timer.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | 4 | class Timer: 5 | def __init__(self): 6 | self.event_time = None 7 | 8 | def start(self, duration): 9 | self.event_time = time.perf_counter() + duration 10 | 11 | def stop(self): 12 | self.event_time = None 13 | 14 | def is_expired(self): 15 | return True if self.event_time is not None and time.perf_counter() >= self.event_time else False 16 | 17 | def remaining(self): 18 | try: 19 | return max(self.event_time - time.perf_counter(), 0) 20 | except: 21 | return 0 22 | 23 | def is_run(self): 24 | return False if self.event_time is None else True 25 | 26 | def is_stop(self): 27 | return True if self.event_time is None else False 28 | 29 | def set_expired(self): 30 | self.event_time = 0 31 | 32 | 33 | if __name__ == "__main__": 34 | 35 | tmr = Timer() 36 | tmr.start(5) 37 | while True: 38 | if tmr.is_run(): 39 | print(tmr.remaining()) 40 | if tmr.is_expired(): 41 | print("TIMER") 42 | tmr.stop() 43 | time.sleep(1) 44 | -------------------------------------------------------------------------------- /trace.py: -------------------------------------------------------------------------------- 1 | from utils import dictget 2 | 3 | 4 | class Trace: 5 | """ 6 | 7 | """ 8 | 9 | def __init__(self): 10 | self.buffer = [] 11 | self.size = 300 # 5min 12 | 13 | def push(self, data): 14 | """ 15 | Push dataset to buffer. Length is limited to self.size 16 | 17 | :param data: dictionary 18 | """ 19 | self.buffer.append(data) # append dataset 20 | if self.size: 21 | self.buffer = self.buffer[-self.size:] # limit to max size 22 | 23 | def get_csv(self, key): 24 | """ 25 | Get trace data as CSV 26 | 27 | [('value', ('device', 'val4')), ... ] 28 | 29 | :param key: dest, source, tuple list with keys (nested, keys allowed) 30 | :return: 31 | """ 32 | try: 33 | # csv = ";".join(columns) + '\n' 34 | csv = ";".join([dest for dest, src in key]) + '\n' 35 | for d in self.buffer: 36 | csv += ";".join(["{}".format(dictget(d, src)) for dest, src in key]) + '\n' 37 | except: 38 | csv = '' 39 | return csv 40 | 41 | def get_chart(self, key): 42 | """ 43 | Get elements from trace in single lists for charting 44 | 45 | [('value', ('device', 'val4')), ... ] 46 | 47 | :param key: dest, source, tuple list with keys (nested, keys allowed) 48 | :return: dictionary 49 | """ 50 | chart = {dest: [] for dest, src in key} 51 | for d in self.buffer: 52 | for dest, src in key: 53 | chart[dest].append(dictget(d, src)) 54 | return chart 55 | 56 | 57 | if __name__ == "__main__": 58 | trace = Trace() 59 | 60 | trace.push({'a': 1, 'b': 10, 'c': {'q': 100}}) 61 | trace.push({'a': 2, 'b': None, 'c': {'q': 200}}) 62 | trace.push({'a': 3, 'b': 30}) 63 | trace.push({'a': 4, 'b': 40, 'c': {'q': 400}}) 64 | 65 | cfg = [('aaa', 'a'), ('bbb', 'b'), ('ccc', ('c', 'q'))] 66 | 67 | print(trace.buffer) 68 | 69 | print(trace.get_chart(cfg)) 70 | 71 | print(trace.get_csv(cfg)) 72 | -------------------------------------------------------------------------------- /us2000.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from datetime import datetime 4 | from threading import Thread 5 | 6 | import serial # pip install pyserial 7 | 8 | from pylontech import read_analog_value, read_alarm_info 9 | 10 | """ 11 | Pylontech / US2000 service class for cyclic polling inside a thread. 12 | 13 | See demo_us2000.py for a simple example. 14 | 15 | { 16 | 'analog': [ 17 | {'u_cell': (3225, 3224, 3225, 3224, 3226, 3226, 3225, 3226, 3228, 3225, 3227, 3227, 3227, 3226, 3225), 18 | 't': [17.5, 16.8, 16.9, 16.5, 17.2], 19 | 'q': 6415, 'q_total': 50000, 'cycle': 132, 'i': -0.1, 'u': 48.386, 'soc': 13}, 20 | 21 | {'u_cell': (3229, 3228, 3229, 3226, 3227, 3227, 3227, 3226, 3228, 3228, 3229, 3226, 3226, 3228, 3228), 22 | 't': [20.0, 17.0, 16.0, 17.0, -100.0], 23 | 'q': 4815, 'q_total': 50000, 'cycle': 376, 'i': 0.0, 'u': 48.412, 'soc': 10} 24 | ], 25 | 'alarm': [ 26 | {'u_cell': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 27 | 't': [0, 0, 0, 0, 0], 'i_chg': 0, 'u_pack': 0, 'i_dis': 0, 'status': [0, 14, 64, 0, 0], 'ready': True}, 28 | 29 | {'u_cell': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 30 | 't': [0, 0, 0, 0, 0], 'i_chg': 0, 'u_pack': 0, 'i_dis': 0, 'status': [0, 14, 0, 0, 0], 'ready': True} 31 | ], 32 | 'analog_timeout': [22300564.212521262, 22300564.212521262], 33 | 'alarm_timeout': [22300564.212521262, 22300564.212521262] 34 | } 35 | 36 | 30.11.2022 Martin Steppuhn Split in pylontech.py (baisc packets) and us2000.py (threaded service class) 37 | """ 38 | 39 | 40 | class US2000: 41 | def __init__(self, port=None, baudrate=115200, pack_number=1, lifetime=10, log_name='us2000', pause=0.25): 42 | """ 43 | Service class with polling thread 44 | 45 | :param port: Serial port, '/dev/ttyUSB0' 46 | :param baudrate: Integer, 9600, 115200, ... 47 | :param pack_number: Number of packs 48 | :param lifetime: Time in seconds data is still valid 49 | :param log_name: Name 50 | :param pause: Pause between polling 51 | """ 52 | self.port = port 53 | self.baudrate = baudrate # save baudrate 54 | self.pack_number = pack_number # number of devices 55 | self.pause = pause 56 | self.lifetime = lifetime 57 | self.log = logging.getLogger(log_name) 58 | self.log.info('init port={}'.format(port)) 59 | self.com = None 60 | self.data = { 61 | 'ready': False, 62 | 'error': None, 63 | 'u': None, 64 | 'i': None, 65 | 't': None, 66 | 'soc': None, 67 | 'u_pack': [None] * self.pack_number, 68 | 'i_pack': [None] * self.pack_number, 69 | 't_pack': [None] * self.pack_number, 70 | 'soc_pack': [None] * self.pack_number, 71 | 'cycle_pack': [None] * self.pack_number, 72 | } 73 | self.data_detail = {"analog": [None] * pack_number, 74 | "alarm": [None] * pack_number, 75 | "analog_timeout": [time.perf_counter() + self.lifetime] * pack_number, 76 | "alarm_timeout": [time.perf_counter() + self.lifetime] * pack_number} 77 | 78 | self.stats = {"frame_count": 0, 79 | "error_analog": [0]*self.pack_number, 80 | "error_alarm": [0]*self.pack_number} 81 | 82 | self.connect() 83 | self.thread = Thread(target=self.run, daemon=True) 84 | self.thread.start() 85 | 86 | def connect(self): 87 | try: 88 | self.com = serial.Serial(self.port, baudrate=self.baudrate, timeout=0.5) # non blocking 89 | except Exception as e: 90 | self.com = None 91 | self.log.error("connect: {}".format(e)) 92 | 93 | 94 | def run(self): 95 | while True: 96 | if self.com is None: 97 | self.connect() 98 | else: 99 | self.stats['frame_count'] += 1 # count read cycle 100 | 101 | for i in range(self.pack_number): 102 | try: 103 | d = read_analog_value(self.com, i) 104 | self.log.debug("read_analog_value[{}] {}".format(i, d)) 105 | self.data_detail['analog'][i] = d 106 | self.data_detail['analog'][i]['time'] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 107 | self.data_detail['analog_timeout'][i] = time.perf_counter() + self.lifetime 108 | except IOError: 109 | self.com = None 110 | self.data_detail['analog'][i] = None 111 | self.log.error("read_analog_value: io port failed") 112 | except Exception as e: 113 | self.data_detail['analog'][i] = None 114 | self.stats['error_analog'][i] += 1 # count error 115 | self.log.debug("EXCEPTION read_analog_value[{}] {}".format(i, e)) 116 | 117 | self.update() 118 | time.sleep(self.pause) 119 | 120 | try: 121 | d = read_alarm_info(self.com, i) 122 | self.log.debug("read_alarm_info[{}] {}".format(i, d)) 123 | self.data_detail['alarm'][i] = d 124 | self.data_detail['alarm'][i]['time'] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") 125 | self.data_detail['alarm_timeout'][i] = time.perf_counter() + self.lifetime 126 | except IOError: 127 | self.com = None 128 | self.data_detail['alarm'][i] = None 129 | self.log.error("read_alarm_info: io port failed") 130 | except Exception as e: 131 | self.data_detail['alarm'][i] = None 132 | self.stats['error_alarm'][i] += 1 # count error 133 | self.log.debug("EXCEPTION read_alarm_info[{}] {}".format(i, e)) 134 | 135 | self.update() 136 | time.sleep(self.pause) 137 | 138 | 139 | 140 | 141 | 142 | 143 | def update(self): 144 | t = time.perf_counter() 145 | 146 | error = None 147 | ready = True 148 | 149 | for i in range(self.pack_number): 150 | try: 151 | if self.data_detail['alarm'][i]['error']: # active error 152 | error = 'alarm' 153 | if self.data_detail['alarm'][i]['ready'] is False: # not ready 154 | ready = False 155 | except: 156 | pass 157 | 158 | try: 159 | if self.data_detail['analog'][i]: 160 | self.data['u_pack'][i] = self.data_detail['analog'][i]['u'] 161 | self.data['i_pack'][i] = self.data_detail['analog'][i]['i'] 162 | self.data['t_pack'][i] = max(self.data_detail['analog'][i]['t']) 163 | self.data['soc_pack'][i] = self.data_detail['analog'][i]['soc'] 164 | self.data['cycle_pack'][i] = self.data_detail['analog'][i]['cycle'] 165 | 166 | except Exception: 167 | self.log.exception("update failed") 168 | pass 169 | 170 | if t > self.data_detail['analog_timeout'][i] or t > self.data_detail['alarm_timeout'][i]: 171 | self.data['u_pack'][i] = None 172 | self.data['i_pack'][i] = None 173 | self.data['t_pack'][i] = None 174 | self.data['soc_pack'][i] = None 175 | self.data['cycle_pack'][i] = None 176 | 177 | error = 'timeout' 178 | ready = False 179 | 180 | 181 | try: 182 | self.data['u'] = max(self.data['u_pack']) 183 | self.data['i'] = sum(self.data['i_pack']) 184 | self.data['t'] = max(self.data['t_pack']) 185 | self.data['soc'] = round(sum(self.data['soc_pack']) / self.pack_number) 186 | except: 187 | self.data['u'] = None 188 | self.data['i'] = None 189 | self.data['t'] = None 190 | self.data['soc'] = None 191 | 192 | self.data['error'] = error 193 | self.data['ready'] = ready 194 | 195 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | def limit(value, min, max): 2 | if value > max: 3 | return max 4 | elif value < min: 5 | return min 6 | else: 7 | return value 8 | 9 | 10 | def dictget(dic, key, default=None): 11 | """ 12 | Recursive get from dictionary. 13 | 14 | :param dic: source dictionary 15 | :param key: key or keylist 16 | :return: value 17 | """ 18 | v = None 19 | try: 20 | if not isinstance(key, (tuple, list)): # normal get from dictionary 21 | v = dic[key] 22 | else: 23 | v = dic 24 | for k in key: # loop over keys 25 | v = v[k] 26 | except (KeyError, IndexError, TypeError): 27 | v = None 28 | 29 | if default is not None and v is None: 30 | return default 31 | else: 32 | return v 33 | 34 | -------------------------------------------------------------------------------- /vebus.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import struct 3 | import time 4 | 5 | import serial 6 | 7 | """ 8 | Victron Energy MK3 Bus Interface 9 | 10 | Functions to control a Multiplus-II in ESS Mode 3 11 | 12 | Based on: Technical-Information-Interfacing-with-VE-Bus-products-MK2-Protocol-3-14.pdf and listening to the 13 | communication between Venus OS and MK3 at the internal FTDI TXD/RXD lines. 14 | 15 | Frame: 16 | 17 | 0xFF ... 18 | 19 | number of bytes, excluding the length and checksum, MSB of is a 1, then this frame has LED status appended 20 | checksum is one byte 21 | 22 | 23 | 23.10.2022 Martin Steppuhn 24 | 27.11.2022 Martin Steppuhn receive_frame() with quick and dirty start search 25 | 22.01.2023 Martin Steppuhn scan for ess assistant (previous hardcoded setpoint at ramid 131) 26 | """ 27 | 28 | 29 | class VEBus: 30 | def __init__(self, port, log='vebus'): 31 | self.port = port 32 | self.ess_setpoint_ram_id = None # RAM-ID for ESS Assistant MP2 3000 = 131 33 | self.log = logging.getLogger(log) 34 | self.serial = None 35 | self.open_port() 36 | 37 | def open_port(self): 38 | try: 39 | self.serial = serial.Serial(self.port, 2400, timeout=0) 40 | except Exception as e: 41 | self.serial = None 42 | self.log.error("open_port: {}".format(e)) 43 | 44 | def get_version(self): 45 | """ 46 | Read versionnumber (MK2). Also used to check connection. 47 | 48 | 007.169 TX: 02 FF 56 A9 V| 49 | 007.211 RX: 07 FF 56 24 DB 11 00 42 52 V| 24 DB 11 00 42 VERSION version=1170212 mode=B 50 | 51 | Firmware laut VE Configure: 2629492 52 | 53 | :return: Versionnumber or None 54 | """ 55 | 56 | if self.serial is None: 57 | self.open_port() # open port 58 | 59 | try: 60 | self.send_frame('V', []) 61 | rx = self.receive_frame(b'\x07\xFF', timeout=0.5) 62 | cmd, mk2_version = struct.unpack("DC -: feed DC>AC 211 | # 15 Inverter Power(filtered) falsches Vorzeichen aber genauer am sollwert 212 | # 16 Output power (filtered) AC-Output +: out -: in 213 | 214 | ~130ms 215 | """ 216 | if self.serial is None: 217 | self.open_port() # open port 218 | 219 | try: 220 | self.send_frame('X', [0x38]) 221 | frame = self.receive_frame(b'\x0D\xFF\x58') 222 | if frame[3] != 0x99: 223 | raise Exception('invalid response') 224 | inv_p, out_p, bat_u, bat_i, soc = struct.unpack("= 0: 363 | break 364 | else: 365 | p = rx.find(head) 366 | 367 | if (p >= 0): 368 | flen = rx[p] + 2 # expected full package length 369 | if (len(rx) - p) >= flen: # rx matches expected full package length 370 | self.log.debug("RX: frame={}".format(self.format_hex(rx[p:p + flen]))) 371 | return rx[p:p + flen] 372 | 373 | if rx: 374 | raise Exception("invalid rx frame {}".format(self.format_hex(rx))) 375 | else: 376 | raise Exception("receive timeout, no data") 377 | 378 | def wakeup(self): 379 | try: 380 | self.serial.write(bytes([0x05, 0x3F, 0x07, 0x00, 0x00, 0x00, 0xC2])) 381 | self.log.info("WAKEUP !!!") 382 | except IOError: 383 | self.serial = None 384 | self.log.error("serial port failed") 385 | except Exception as e: 386 | self.log.error("wakeup: {}".format(e)) 387 | 388 | def sleep(self): 389 | """ 390 | Set Multiplus in Sleepmode by command 391 | 392 | Standby consumption: ~1,3 Watt DC: 27mA AC: 0.0 Watt 393 | """ 394 | try: 395 | self.serial.write(bytes([0x05, 0x3F, 0x04, 0x00, 0x00, 0x00, 0xC5])) 396 | self.log.info("SLEEP !!!") 397 | except IOError: 398 | self.serial = None 399 | self.log.error("serial port failed") 400 | except Exception as e: 401 | self.log.error("sleep: {}".format(e)) 402 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.9.9" 2 | -------------------------------------------------------------------------------- /web.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import threading 5 | 6 | import bottle 7 | from bottle import Bottle, request, response, static_file, redirect, template 8 | 9 | from config import config 10 | from session import Session 11 | from utils import dictget 12 | from version import __version__ 13 | 14 | bottle.TEMPLATE_PATH = [config['www_path']] # must be a list !!! 15 | 16 | 17 | class AppWeb: 18 | """ 19 | Webserverintegration for application 20 | """ 21 | 22 | def __init__(self, app, log='web'): 23 | self.app = app # reference to application 24 | self.log = logging.getLogger(log) 25 | self.web = Bottle() # webserver 26 | 27 | self.session = Session(password=config['password']) 28 | 29 | self.manual_session = None # session of the client which has manual control 30 | 31 | # setup routes 32 | self.web.route('/', callback=self.web_index) # hosting static files 33 | self.web.route('/login', callback=self.web_login, method=('GET', 'POST')) # application state in json format 34 | self.web.route('/api/state', callback=self.web_api_state, method=('GET', 'POST')) 35 | self.web.route('/api/state/', callback=self.web_api_state) 36 | self.web.route('/api/set', callback=self.web_api_set, method=('GET', 'POST')) 37 | self.web.route('/api/bms', callback=self.web_api_bms) # full bms data in json format 38 | self.web.route('/debug/', callback=self.web_debug_cmd) # debug commands 39 | self.web.route('/log', callback=self.web_log) # access to logfile 40 | self.web.route('/chart', callback=self.web_chart) # 41 | self.web.route('/blackbox', callback=lambda : "\n".join(self.app.blackbox.record_lines)) # 42 | self.web.route('/', callback=self.web_static) # hosting static files 43 | 44 | logging.getLogger('waitress.queue').setLevel(logging.ERROR) # hide waitress info log 45 | # start webserver thread 46 | threading.Thread(target=self.web.run, daemon=True, 47 | kwargs=dict(host='0.0.0.0', port=config['http_port'], server='waitress')).start() 48 | 49 | def web_login(self): 50 | """ 51 | Login view 52 | :return: 53 | """ 54 | password = request.forms.get("password", default=None) 55 | remote_addr = request.environ.get('REMOTE_ADDR') 56 | if password is None: 57 | self.log.info("login form request from {}".format(remote_addr)) 58 | elif self.session.login(password): 59 | self.log.info("login from {} successful, redirect to /".format(remote_addr)) 60 | redirect('/') 61 | return 62 | else: 63 | self.log.error("login attempt from {} with invalid password: {}".format(remote_addr, password)) 64 | return static_file('login.html', root=config['www_path']) 65 | 66 | def web_index(self, filepath='index.html'): 67 | """ 68 | Webserver interface for static files 69 | """ 70 | session_id = request.get_cookie('session') 71 | remote_addr = request.environ.get('REMOTE_ADDR') 72 | 73 | if self.session.is_valid(session_id): # static files only after login (session) 74 | self.log.debug( 75 | "request to: {} from: {} with valid session_id: {}".format(filepath, remote_addr, session_id)) 76 | 77 | bottle.TEMPLATES.clear() # DEBUG !!!! 78 | 79 | # https://pwp.stevecassidy.net/bottle/templating/ 80 | 81 | d = { 82 | "version": __version__, 83 | "bms_pack_number": 1, 84 | "enable_car": config['enable_car'], 85 | "enable_heat": config['enable_heat'], 86 | "setting": [s['name'] for s in config['setting']] 87 | } 88 | 89 | try: 90 | d["bms_pack_number"] = config['bms_us2000']['pack_number'] 91 | except: 92 | pass 93 | 94 | 95 | return template('index.html', d) 96 | # return static_file('index.html', root=self.app.www_path) 97 | else: 98 | if session_id: 99 | self.log.info( 100 | "index request from:{} unknown session_id: {}, redirect to /login".format(remote_addr, session_id)) 101 | else: 102 | self.log.info( 103 | "index request from:{} without session_id, redirect to /login".format(filepath, remote_addr)) 104 | redirect('/login') 105 | 106 | def web_static(self, filepath=None): 107 | """ 108 | Webserver interface for static files 109 | """ 110 | if filepath == 'index.html': 111 | self.web_index() 112 | else: 113 | return static_file(filepath, root=self.app.www_path) 114 | 115 | def web_log(self): 116 | """ 117 | /log Webserver interface to access the logfile 118 | """ 119 | response.content_type = 'text/plain' 120 | return open(os.path.join('log', 'log.txt'), 'r').read() 121 | 122 | def web_api_state(self): 123 | """ 124 | /api/state Webserver interface to get application state in JSON 125 | """ 126 | 127 | try: 128 | post = json.loads(request.body.read()) # manual_set_p 129 | self.app.ui_command = post 130 | # print("web post ui_command", post) 131 | except: 132 | pass 133 | 134 | session_id = request.get_cookie('session') 135 | 136 | state = self.app.get_state() 137 | 138 | # manual auth 139 | if self.app.mode == 'manual' and self.manual_session and session_id == self.manual_session: 140 | state['manual_auth'] = True 141 | 142 | # valid session 143 | if not self.session.is_valid(session_id): 144 | state['session_invalid'] = True 145 | 146 | return state 147 | 148 | def web_api_set(self): 149 | """ 150 | /api/set Webserver interface to set by JSON 151 | """ 152 | session_id = request.get_cookie('session') 153 | 154 | if self.session.is_valid(session_id): 155 | try: 156 | post = json.loads(request.body.read()) # manual_set_p 157 | self.log.info("/api/set {}".format(post)) 158 | 159 | if 'option' in post: 160 | self.app.setting = int(post['option']) 161 | 162 | if 'mode' in post and post['mode'] in ('off', 'auto', 'manual'): 163 | self.app.mode = post['mode'] 164 | self.app.charge_start_timer.set_expired() # trigger 165 | self.app.feed_start_timer.set_expired() # trigger 166 | 167 | if post['mode'] == 'manual': 168 | self.manual_session = session_id 169 | self.log.info('manual control for session: {} started'.format(session_id)) 170 | else: 171 | self.manual_session = None 172 | 173 | if 'reset_error' in post and post['reset_error'] is True: # init = Error reset 174 | self.log.info("manual error reset !") 175 | self.app.set_fsm_state('init') 176 | 177 | except Exception as e: 178 | self.log.error('/api/set exception: {}'.format(e)) 179 | else: 180 | self.log.error('/api/set without valid session') 181 | return self.app.get_state() 182 | 183 | def web_api_bms(self): 184 | """ 185 | /api/state Webserver interface to get full bms info as json 186 | """ 187 | response.content_type = 'application/json' 188 | return json.dumps(self.app.bms.get_detail()) 189 | 190 | def web_debug_cmd(self, cmd): 191 | """ 192 | 193 | """ 194 | if cmd == 'mp2online': 195 | s = "mp2online debug command" 196 | self.app.multiplus.online = True 197 | else: 198 | s = "unknown debug command: {}".format(cmd) 199 | self.log.info(s) 200 | return s 201 | 202 | def web_chart(self): 203 | """ 204 | 205 | """ 206 | # chart = self.app.trace.get_chart([('home', ('meterhub', 'home_all_p')), ('grid', ('meterhub', 'grid_p')), ('grid', ('meterhub', 'car_p'))]) 207 | 208 | chart = {'x': [], 'home': [], 'grid': [], 'bat_feed': [], 'bat_charge': [], 'charge_set': [], 'feed_set': []} 209 | 210 | i = 0 211 | for t in self.app.trace.buffer: 212 | 213 | grid = dictget(t, ('meterhub', 'grid_p')) 214 | bat = dictget(t, ('meterhub', 'bat_p')) 215 | home_all = dictget(t, ('meterhub', 'home_all_p')) 216 | car = dictget(t, ('meterhub', 'car_p'), 0) 217 | home = dictget(t, ('meterhub', 'home_p')) 218 | 219 | set_p = dictget(t, ('ess', 'set_p')) 220 | try: 221 | charge_set = max(set_p, 0) 222 | feed_set = max(-set_p, 0) 223 | except: 224 | charge_set = None 225 | feed_set = None 226 | 227 | chart['home'].append(home) 228 | chart['grid'].append(grid) 229 | 230 | chart['bat_charge'].append(bat if bat and bat > 0 else 0) 231 | chart['bat_feed'].append(-bat if bat and bat < 0 else 0) 232 | chart['charge_set'].append(charge_set) 233 | chart['feed_set'].append(feed_set) 234 | 235 | chart['x'].append(i) 236 | i += 1 237 | 238 | response.content_type = 'application/javascript' 239 | return "var chart =" + json.dumps(chart) 240 | -------------------------------------------------------------------------------- /www/app.js: -------------------------------------------------------------------------------- 1 | var app = app || {}; 2 | 3 | /** 4 | * Helper for a short document.getElementById() 5 | * 6 | * @param id 7 | * @returns {HTMLElement} 8 | */ 9 | $id = function (id) { 10 | return document.getElementById(id); 11 | } 12 | 13 | app.init = function () { 14 | var self = app; 15 | console.log("app.init"); 16 | 17 | self.manual_cmd = null; 18 | 19 | // Setup PowerFlow view (https://github.com/martiby/PowerFlow) 20 | // {id: 'car', type: 'car', sign: -1, wallbox: true}], 21 | const pflow_config = { 22 | table: [ 23 | {id: 'pv', type: 'pv'}, 24 | {id: 'home', type: 'home', sign: -1}, 25 | {id: 'bat', type: 'bat', sign: -1}, 26 | {id: 'grid', type: 'grid'} ], 27 | bar: { 28 | in: [ 29 | {id: 'pv', type: 'pv'}, 30 | {id: 'bat', type: 'bat', sign: -1}, 31 | {id: 'grid', type: 'grid'}], 32 | out: [ 33 | {id: 'home', type: 'home'}, 34 | {id: 'car', type: 'car'}, 35 | {id: 'heat', type: 'heat'}, 36 | {id: 'bat', type: 'bat'}, 37 | {id: 'grid', type: 'grid', sign: -1}] 38 | } 39 | }; 40 | 41 | if(config_enable_heat) { 42 | pflow_config.table.push({id: 'heat', type: 'heat', sign: -1}); 43 | 44 | } 45 | 46 | if(config_enable_car) { 47 | pflow_config.table.push({id: 'car', type: 'car', sign: -1, wallbox: true}); 48 | } 49 | 50 | app.pflow = new Pflow('pflow-table', 'pflow-bar', pflow_config); // Init PowerFlow 51 | 52 | 53 | // event for info logo to toggle detail information 54 | 55 | $id('info-logo').addEventListener('click', function () { 56 | let ele = $id('container-detail'); 57 | if (ele.style.display === "none") { 58 | ele.style.display = "block"; 59 | $id('pflow-table').style.display = 'none'; 60 | } else { 61 | ele.style.display = "none"; 62 | $id('pflow-table').style.display = 'block'; 63 | } 64 | }); 65 | 66 | // event for option dropdown (std, chg_boost, feed_boost) 67 | 68 | ['change', 'tap'].forEach(evt => 69 | $id('option-select').addEventListener(evt, function () { 70 | let post = {'option': $id('option-select').value}; 71 | console.log("set option", post); 72 | fetch_json('api/set', self.show_state, null, post); 73 | }, false) 74 | ); 75 | 76 | // event for mode buttons 77 | 78 | document.querySelectorAll("[data-mode]").forEach(button => { 79 | button.addEventListener("click", (evt) => { 80 | let mode = button.getAttribute('data-mode'); 81 | fetch_json('api/set', self.show_state, null, {'mode': mode}); 82 | }) 83 | }); 84 | 85 | $id('btn-error-reset').addEventListener('click', function () { 86 | fetch_json('api/set', self.show_state, null, {'reset_error': true}); 87 | }); 88 | 89 | $id('manual-charge-slider').addEventListener('input', function (evt) { 90 | $id('manual-charge-info').textContent = evt.target.value; 91 | $id('manual-feed-slider').value = 0; 92 | $id('manual-feed-info').textContent = '0'; 93 | }); 94 | 95 | $id('manual-feed-slider').addEventListener('input', function (evt) { 96 | $id('manual-feed-info').textContent = evt.target.value; 97 | $id('manual-charge-slider').value = 0; 98 | $id('manual-charge-info').textContent = '0'; 99 | }); 100 | 101 | $id('btn-manual-wakeup').addEventListener('click', function () { 102 | app.manual_cmd = 'wakeup'; 103 | }); 104 | $id('btn-manual-sleep').addEventListener('click', function () { 105 | app.manual_cmd = 'sleep'; 106 | }); 107 | 108 | app.poll_state(); 109 | } 110 | 111 | /** 112 | * Cyclic polling the status (/api/status) 113 | */ 114 | app.poll_state = function () { 115 | let self = app; 116 | let post = null; 117 | 118 | // nur mit lokal aktiviertem manual wird gesendet 119 | 120 | if (self.state?.manual_auth) { 121 | post = {'manual_set_p': $id('manual-charge-slider').value - $id('manual-feed-slider').value}; 122 | if (self.manual_cmd) post['manual_cmd'] = self.manual_cmd; 123 | self.manual_cmd = null; 124 | console.log('manual', post) 125 | } 126 | 127 | fetch_json('api/state', 128 | function (response) { 129 | self.show_state(response) 130 | }, function (error) { 131 | console.log('poll_state ERROR', error); 132 | self.show_state(null) 133 | }, post); 134 | setTimeout(self.poll_state, 1000); 135 | }; 136 | 137 | /** 138 | * Successfull poll 139 | * 140 | * @param state 141 | */ 142 | app.show_state = function (state) { 143 | let self = app; 144 | 145 | // state.ess.state = 'error'; // Fake for test 146 | // state.meterhub.error = 'error'; // Fake for test 147 | // state.multiplus.error = 'error'; // Fake for test 148 | // state.bms.error = 'error'; // Fake for test 149 | // console.log(state); 150 | 151 | // fake state 152 | // state.ess.time = "2022-11-21 14:54"; 153 | // state.ess.info = "Automatik - Laden"; 154 | // state.meterhub.pv1_p = 1265; 155 | // state.meterhub.pv2_p = 1608; 156 | // state.meterhub.pv_p = 2873; 157 | // state.meterhub.home_p = 432; 158 | // state.meterhub.bat_p = 1602; 159 | // state.meterhub.grid_p = -839; 160 | // state.bms.soc = 81; 161 | 162 | self.state = state; 163 | 164 | // reload if session is invalid 165 | 166 | if (state?.session_invalid === true) { 167 | console.log("received state.session_invalid"); 168 | document.body.innerHTML = "

Invalid session

starting reload...

"; 169 | setTimeout(function () { 170 | location.reload(); 171 | }, 2000); 172 | } 173 | 174 | // add frame in manual mode 175 | 176 | let ele = $id('container-main'); 177 | if (state?.ess?.mode === 'manual') { 178 | if (state?.manual_auth === true) ele.style.border = '10px solid #ec3030'; 179 | else ele.style.border = '10px solid #fda042'; 180 | } else { 181 | ele.style.border = null; 182 | } 183 | 184 | // show time in headline 185 | try { 186 | $id('time').textContent = state.ess.time.slice(11) 187 | } catch (e) { 188 | $id('time').textContent = "DISCONNECTED"; 189 | } 190 | 191 | self.show_dashboard_state(state) 192 | self.show_detail_values(state); 193 | } 194 | 195 | /** 196 | * Show state for main dashboard view 197 | * 198 | * @param state 199 | */ 200 | app.show_dashboard_state = function (state) { 201 | let self = app; 202 | 203 | // === PFlow === 204 | let d = { 205 | pv: { 206 | power: state?.meterhub?.pv_p, 207 | subline: 'Süd: ' + (state?.meterhub?.pv1_p ?? '---') + ' W Nord: ' + (state?.meterhub?.pv2_p ?? '---') + ' W', 208 | }, 209 | home: {power: state?.meterhub?.home_p}, 210 | bat: { 211 | power: state?.meterhub?.bat_p, 212 | error: state?.ess?.state === 'error', 213 | info: (state?.bms?.soc) ? state.bms.soc + ' %' : '-- %', 214 | subline: state?.ess?.info 215 | }, 216 | car: { 217 | power: state?.meterhub?.car_p, 218 | disable: !state?.meterhub?.car_plug, 219 | info: ((state?.meterhub?.car_e_cycle ?? 0) > 0) ? (state?.meterhub?.car_e_cycle / 1000).toFixed(1) + ' kWh' : '', 220 | subline: state?.meterhub?.car_info, 221 | 222 | wallbox_pvready: state?.meterhub?.car_pv_ready, 223 | wallbox_stop: state?.meterhub?.car_stop, 224 | wallbox_amp: (state?.meterhub?.car_phase && state?.meterhub?.car_amp) ? state?.meterhub?.car_phase + 'x' + state?.meterhub?.car_amp + 'A' : 'xxx' 225 | 226 | }, 227 | heat: { 228 | power: state?.meterhub?.heat_p 229 | }, 230 | grid: { 231 | power: state?.meterhub?.grid_p 232 | } 233 | }; 234 | self.pflow.update(d); 235 | $id('option-select').value = state?.ess?.setting ?? ''; 236 | } 237 | 238 | /** 239 | * Show state for detail view 240 | * 241 | * @param state 242 | */ 243 | app.show_detail_values = function (state) { 244 | let self = app; 245 | 246 | set_val = function (id, value, suffix, precision) { 247 | $id(id).textContent = fmtNumber(value, precision) + suffix; 248 | } 249 | 250 | // set mode buttons 251 | document.querySelectorAll("[data-mode]").forEach(button => { 252 | let b = (button.getAttribute('data-mode') === state?.ess?.mode); 253 | button.classList.toggle('btn-primary', b); 254 | button.classList.toggle('btn-outline-primary', !b); 255 | }); 256 | 257 | // set title on error 258 | $id('meterhub-title').style.backgroundColor = (state?.meterhub?.error) ? '#ff4f4f' : '#fff'; 259 | $id('mp2-title').style.backgroundColor = (state?.multiplus?.error) ? '#ff4f4f' : '#fff'; 260 | $id('bms-title').style.backgroundColor = (state?.bms?.error) ? '#ff4f4f' : '#fff'; 261 | 262 | // show reset button on error 263 | $id('btn-error-reset').style.display = (state?.ess?.state === 'error') ? 'block' : 'none'; 264 | 265 | // ess table values 266 | set_val('ess-set-p', state?.ess?.set_p, ' W'); 267 | $id('ess-mode').textContent = state?.ess?.mode ?? '?'; 268 | $id('ess-state').textContent = state?.ess?.state ?? '?'; 269 | 270 | // Meterhub 271 | // let car_p = state?.meterhub?.car_p; 272 | // let home_all_p = state?.meterhub?.home_all_p; 273 | // let home_p = home_all_p === null || car_p === null ? home_all_p : home_all_p - car_p; 274 | 275 | set_val('meter-pv-p', state?.meterhub?.pv_p, ' W'); 276 | set_val('meter-grid-p', state?.meterhub?.grid_p, ' W'); 277 | set_val('meter-home-p', state?.meterhub?.home_p, ' W'); 278 | set_val('meter-bat-p', state?.meterhub?.bat_p, ' W'); 279 | if(config_enable_car) set_val('meter-car-p', state?.meterhub?.car_p, ' W'); 280 | if(config_enable_heat) set_val('meter-heat-p', state?.meterhub?.heat_p, ' W'); 281 | 282 | // Multiplus 283 | $id('mp2-state').textContent = state?.multiplus?.state ?? '--'; 284 | set_val('mp2-inv-p', state?.multiplus?.inv_p, ' W'); 285 | set_val('mp2-bat-p', state?.multiplus?.bat_p, ' W'); 286 | set_val('mp2-bat-u', state?.multiplus?.bat_u, ' V', 1); 287 | set_val('mp2-bat-i', state?.multiplus?.bat_i, ' A', 1); 288 | set_val('mp2-mains-u', state?.multiplus?.mains_u, ' V', 1); 289 | set_val('mp2-mains-i', state?.multiplus?.mains_i, ' A', 1); 290 | 291 | // BMS / US2000 292 | for (let n = 0; n < (state?.bms?.pack_u ?? []).length; n++) { 293 | let u = state?.bms?.pack_u?.[n]; 294 | let i = state?.bms?.pack_i?.[n]; 295 | let p = u === null || i === null ? null : u * i; 296 | 297 | try { 298 | set_val('bms-soc' + n, state?.bms?.pack_soc?.[n], '%'); 299 | set_val('bms-p' + n, p, 'W'); 300 | set_val('bms-u' + n, u, 'V', 2); 301 | set_val('bms-i' + n, i, 'A', 1); 302 | set_val('bms-t' + n, state?.bms?.pack_t?.[n], '°C'); 303 | set_val('bms-cycle' + n, state?.bms?.pack_cycle?.[n], 'x'); 304 | } catch (e) { 305 | } 306 | } 307 | $id('container-manual').style.display = (state?.manual_auth === true) ? 'block' : 'none'; 308 | } 309 | 310 | 311 | window.onload = function () { 312 | app.init(); 313 | } 314 | 315 | -------------------------------------------------------------------------------- /www/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/www/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/martiby/ess/589ddca88a9a9326a0738e7721bc85eb8de707b4/www/favicon.ico -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | ESS 16 | 17 | 128 | 129 | 130 | 131 | 132 |
133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 |
143 | 144 |
145 | 146 | 147 | 148 |
149 |
150 |
151 | 152 | 153 | 154 |
155 |
ESS
156 |
157 |
158 |
159 | 164 |
165 |
166 | 167 |
168 | 169 | 170 | 171 |
172 | 173 | 174 | 175 | 337 | 338 | 339 | 340 |
341 | 342 |
343 | 348 |
349 | 350 |
351 | 352 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | -------------------------------------------------------------------------------- /www/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 82 | 83 | 84 | 85 |
86 |
87 |

88 | 89 |
90 | 91 | 92 | -------------------------------------------------------------------------------- /www/nobs.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | body { 8 | margin: 0; 9 | font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 10 | font-size: 1rem; 11 | font-weight: 400; 12 | line-height: 1.5; 13 | color: #212529; 14 | background-color: #fff; 15 | -webkit-text-size-adjust: 100%; 16 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 17 | } 18 | 19 | input, 20 | button, 21 | select, 22 | optgroup, 23 | textarea { 24 | margin: 0; 25 | font-family: inherit; 26 | font-size: inherit; 27 | line-height: inherit; 28 | } 29 | 30 | button, 31 | input[type='submit'], 32 | input[type='button'], 33 | input[type='checkbox'] { 34 | cursor: pointer; 35 | } 36 | 37 | input:disabled, 38 | select:disabled, 39 | button:disabled, 40 | textarea:disabled { 41 | cursor: not-allowed; 42 | opacity: .5; 43 | } 44 | 45 | .container { 46 | width: 100%; 47 | padding-right: calc(1.5rem * 0.5); 48 | padding-left: calc(1.5rem * 0.5); 49 | margin-right: auto; 50 | margin-left: auto; 51 | } 52 | 53 | 54 | hr { 55 | /*margin: 1rem 0;*/ 56 | /*color: inherit;*/ 57 | border: 0; 58 | border-top: 1px solid; 59 | opacity: 0.25; 60 | } 61 | 62 | /* === Button === */ 63 | 64 | .btn { 65 | --bs-btn-padding-x: 0.75rem; 66 | --bs-btn-padding-y: 0.375rem; 67 | --bs-btn-font-size: 1rem; 68 | --bs-btn-font-weight: 400; 69 | --bs-btn-line-height: 1.5; 70 | --bs-btn-border-width: 1px; 71 | --bs-btn-border-radius: 0.25rem; 72 | display: inline-block; 73 | padding: var(--bs-btn-padding-y) var(--bs-btn-padding-x); 74 | font-family: var(--bs-btn-font-family); 75 | font-size: var(--bs-btn-font-size); 76 | font-weight: var(--bs-btn-font-weight); 77 | line-height: var(--bs-btn-line-height); 78 | color: var(--bs-btn-color); 79 | text-align: center; 80 | text-decoration: none; 81 | vertical-align: middle; 82 | cursor: pointer; 83 | user-select: none; 84 | border: var(--bs-btn-border-width) solid var(--bs-btn-border-color); 85 | border-radius: var(--bs-btn-border-radius); 86 | background-color: var(--bs-btn-bg); 87 | transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 88 | } 89 | 90 | .btn-primary { 91 | --bs-btn-color: #fff; 92 | --bs-btn-bg: #0d6efd; 93 | --bs-btn-border-color: #0d6efd; 94 | --bs-btn-hover-color: #fff; 95 | --bs-btn-hover-bg: #0b5ed7; 96 | --bs-btn-hover-border-color: #0a58ca; 97 | --bs-btn-focus-shadow-rgb: 49, 132, 253; 98 | } 99 | 100 | .btn-secondary { 101 | --bs-btn-color: #fff; 102 | --bs-btn-bg: #6c757d; 103 | --bs-btn-border-color: #6c757d; 104 | --bs-btn-hover-color: #fff; 105 | --bs-btn-hover-bg: #5c636a; 106 | --bs-btn-hover-border-color: #565e64; 107 | --bs-btn-focus-shadow-rgb: 130, 138, 145; 108 | } 109 | 110 | .btn-danger { 111 | --bs-btn-color: #fff; 112 | --bs-btn-bg: #dc3545; 113 | --bs-btn-border-color: #dc3545; 114 | --bs-btn-hover-color: #fff; 115 | --bs-btn-hover-bg: #bb2d3b; 116 | --bs-btn-hover-border-color: #b02a37; 117 | --bs-btn-focus-shadow-rgb: 225, 83, 97; 118 | } 119 | 120 | .btn-outline-primary { 121 | --bs-btn-color: #0d6efd; 122 | --bs-btn-border-color: #0d6efd; 123 | --bs-btn-hover-color: #fff; 124 | --bs-btn-hover-bg: #0d6efd; 125 | --bs-btn-hover-border-color: #0d6efd; 126 | --bs-btn-focus-shadow-rgb: 13, 110, 253; 127 | } 128 | 129 | .btn-outline-secondary { 130 | --bs-btn-color: #6c757d; 131 | --bs-btn-border-color: #6c757d; 132 | --bs-btn-hover-color: #fff; 133 | --bs-btn-hover-bg: #6c757d; 134 | --bs-btn-hover-border-color: #6c757d; 135 | --bs-btn-focus-shadow-rgb: 108, 117, 125; 136 | } 137 | 138 | .btn-outline-danger { 139 | --bs-btn-color: #dc3545; 140 | --bs-btn-border-color: #dc3545; 141 | --bs-btn-hover-color: #fff; 142 | --bs-btn-hover-bg: #dc3545; 143 | --bs-btn-hover-border-color: #dc3545; 144 | --bs-btn-focus-shadow-rgb: 220, 53, 69; 145 | } 146 | 147 | .btn:hover:not([disabled]) { 148 | color: var(--bs-btn-hover-color); 149 | background-color: var(--bs-btn-hover-bg); 150 | border-color: var(--bs-btn-hover-border-color); 151 | } 152 | 153 | 154 | /* === Buttongroup === */ 155 | 156 | .btn-group { 157 | border-radius: 0.375rem; 158 | /*position: relative;*/ 159 | display: inline-flex; 160 | } 161 | 162 | .btn-group > .btn:not(:first-child), 163 | .btn-group > .btn-group:not(:first-child) { 164 | margin-left: -1px; 165 | } 166 | 167 | .btn-group > .btn:not(:last-child):not(.dropdown-toggle), 168 | .btn-group > .btn.dropdown-toggle-split:first-child, 169 | .btn-group > .btn-group:not(:last-child) > .btn { 170 | border-top-right-radius: 0; 171 | border-bottom-right-radius: 0; 172 | } 173 | 174 | .btn-group > .btn:nth-child(n+3), 175 | .btn-group > :not(.btn-check) + .btn, 176 | .btn-group > .btn-group:not(:first-child) > .btn { 177 | border-top-left-radius: 0; 178 | border-bottom-left-radius: 0; 179 | } 180 | 181 | /* === Select === */ 182 | 183 | .form-select { 184 | display: block; 185 | width: 100%; 186 | padding: 0.375rem 2.25rem 0.375rem 0.75rem; 187 | -moz-padding-start: calc(0.75rem - 3px); 188 | font-size: 1rem; 189 | font-weight: 400; 190 | line-height: 1.5; 191 | color: #212529; 192 | background-color: #fff; 193 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); 194 | background-repeat: no-repeat; 195 | background-position: right 0.75rem center; 196 | background-size: 16px 12px; 197 | border: 1px solid #ced4da; 198 | border-radius: 0.375rem; 199 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 200 | -webkit-appearance: none; 201 | -moz-appearance: none; 202 | appearance: none; 203 | } 204 | 205 | .form-select:focus { 206 | border-color: #86b7fe; 207 | outline: 0; 208 | box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); 209 | } 210 | 211 | .form-select-sm { 212 | padding-top: 0.25rem; 213 | padding-bottom: 0.25rem; 214 | padding-left: 0.5rem; 215 | font-size: 0.875rem; 216 | border-radius: 0.25rem; 217 | } 218 | 219 | .form-select-lg { 220 | padding-top: 0.5rem; 221 | padding-bottom: 0.5rem; 222 | padding-left: 1rem; 223 | font-size: 1.25rem; 224 | border-radius: 0.5rem; 225 | } 226 | 227 | 228 | /* === Range === */ 229 | 230 | .form-range { 231 | width: 100%; 232 | height: 1.5rem; 233 | padding: 0; 234 | background-color: transparent; 235 | -webkit-appearance: none; 236 | -moz-appearance: none; 237 | appearance: none; 238 | } 239 | 240 | .form-range:focus { 241 | outline: 0; 242 | } 243 | 244 | .form-range:focus::-webkit-slider-thumb { 245 | box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(13, 110, 253, 0.25); 246 | } 247 | .form-range:focus::-moz-range-thumb { 248 | box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(13, 110, 253, 0.25); 249 | } 250 | .form-range::-moz-focus-outer { 251 | border: 0; 252 | } 253 | 254 | .form-range::-webkit-slider-thumb { 255 | width: 1rem; 256 | height: 1rem; 257 | margin-top: -0.25rem; 258 | background-color: #0d6efd; 259 | border: 0; 260 | border-radius: 1rem; 261 | -webkit-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 262 | transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 263 | -webkit-appearance: none; 264 | appearance: none; 265 | } 266 | 267 | @media (prefers-reduced-motion: reduce) { 268 | .form-range::-webkit-slider-thumb { 269 | -webkit-transition: none; 270 | transition: none; 271 | } 272 | } 273 | 274 | .form-range::-webkit-slider-thumb:active { 275 | background-color: #b6d4fe; 276 | } 277 | .form-range::-webkit-slider-runnable-track { 278 | width: 100%; 279 | height: 0.5rem; 280 | color: transparent; 281 | cursor: pointer; 282 | background-color: #dee2e6; 283 | border-color: transparent; 284 | border-radius: 1rem; 285 | } 286 | 287 | .form-range::-moz-range-thumb { 288 | width: 1rem; 289 | height: 1rem; 290 | background-color: #0d6efd; 291 | border: 0; 292 | border-radius: 1rem; 293 | -moz-transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 294 | transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 295 | -moz-appearance: none; 296 | appearance: none; 297 | } 298 | 299 | .form-range::-moz-range-thumb:active { 300 | background-color: #b6d4fe; 301 | } 302 | 303 | .form-range::-moz-range-track { 304 | width: 100%; 305 | height: 0.5rem; 306 | color: transparent; 307 | cursor: pointer; 308 | background-color: #dee2e6; 309 | border-color: transparent; 310 | border-radius: 1rem; 311 | } -------------------------------------------------------------------------------- /www/pflow.js: -------------------------------------------------------------------------------- 1 | /** 2 | 3 | HTML/Javascript view for energy flow in building automation 4 | 5 | Setup example: 6 | var config = { 7 | table: [ 8 | {id: 'pv', type: 'pv'}, 9 | {id: 'home', type: 'home', sign: -1}, 10 | {id: 'bat', type: 'bat', sign: -1}, 11 | {id: 'grid', type: 'grid'}, 12 | {id: 'car', type: 'car', sign: -1, wallbox: true}], 13 | bar: { 14 | in: [ 15 | {id: 'pv', type: 'pv'}, 16 | {id: 'bat', type: 'bat', sign: -1}, 17 | {id: 'grid', type: 'grid'}], 18 | out: [ 19 | {id: 'home', type: 'home'}, 20 | {id: 'car', type: 'car'}, 21 | {id: 'bat', type: 'bat'}, 22 | {id: 'grid', type: 'grid', sign: -1}] 23 | } 24 | } 25 | pflow = new Pflow('pflow-table', 'pflow-bar', setup); 26 | 27 | 28 | Data example: 29 | var d = { 30 | pv: { 31 | power: 2533, 32 | subline: 'Süd: 600 W Nord: 400 W'}, 33 | home: { 34 | power: 411 }, 35 | bat: { 36 | power: 0, 37 | info: '90%', 38 | subline: 'Automatik - Schlafen'}, 39 | car: { 40 | power: 0, 41 | info: '11.2 kWh', 42 | subline: 'PV - Laden gesperrt', 43 | disable: false, 44 | wallbox_pvready: false, 45 | wallbox_stop: true, 46 | wallbox_amp: '1x6A'}, 47 | grid: { 48 | power: -2122 49 | }, 50 | }; 51 | 52 | pflow.update(d); 53 | 54 | Config details: 55 | 56 | table 57 | id identifier for access 58 | type set to use default color and icon [pv, home, bat, grid, car heat] 59 | sign -1 to invert the direction for the arrow 60 | icon manual icon setting, e.g.: 'svg-house' 61 | wallbox true to activate wallbox options 62 | 63 | bar.* 64 | id identifier for access 65 | type set to use default color and icon [pv, home, bat, grid, car heat] 66 | sign -1 for negative power input to be positive 67 | icon manual icon setting, e.g.: 'svg-house' 68 | 69 | Data details: 70 | 71 | power: Power in watt 72 | info: Text behind power 73 | subline: Infotext below power and info 74 | disable: Icon is shown disabled 75 | error: Icon is shown as error 76 | 77 | wallbox_pvready: sun or cloud icon 78 | wallbox_stop: badge with phase and ampere is shown diabled 79 | wallbox_amp: Badge for phase and ampere info '3x16A' 80 | 81 | 82 | 07.11.2022 Martin Steppuhn 83 | 26.11.2022 Martin Steppuhn error for icon 84 | 85 | 86 | */ 87 | 88 | class Pflow { 89 | /** 90 | * Constructor/Init 91 | * 92 | * @param table_id Element ID for the table view 93 | * @param bar_id Element ID for the bars view 94 | * @param config Configuration 95 | */ 96 | constructor(table_id, bar_id, config) { 97 | this.table_id = table_id; // container for table 98 | this.bar_id = bar_id; // container for in and out bar 99 | this.config = config; // complete configuration 100 | this.arrow_power_scale = 2000; // power in watt for max arrow size 101 | 102 | // if not specified in config, the icon ist dedicated by the type pv --> svg-sun 103 | this.default_icons = { 104 | pv: 'svg-sun', 105 | home: 'svg-house', 106 | bat: 'svg-car-battery', 107 | grid: 'svg-industry', 108 | car: 'svg-car', 109 | heat: 'svg-fire' 110 | }; 111 | 112 | // Init 113 | this.init_resource(); // append svgs and styles 114 | this.init_table(); 115 | this.init_bar('in'); 116 | this.init_bar('out'); 117 | this.update(); 118 | } 119 | 120 | /** 121 | * Init table view 122 | */ 123 | init_table() { 124 | var element = document.getElementById(this.table_id); 125 | 126 | if (!element || !(this.config?.table ?? null)) { // abort without container or config 127 | this.config.table = null; 128 | return; 129 | } 130 | 131 | var s = ''; 132 | var cfg = this.config.table; 133 | for (var i = 0; i < cfg.length; i++) { 134 | var row = cfg[i]; 135 | 136 | if (row.wallbox) { 137 | var wallbox = ` 138 | 139 |
`; 140 | } else wallbox = ""; 141 | 142 | var svg_icon = row?.icon ?? this.default_icons[row.type]; // get custom or default icon 143 | 144 | // "Template" for a single table row 145 | s += ` 146 |
147 |
148 | 149 | 150 | 151 |
152 |
153 |
154 | 155 | ${wallbox} 156 |
157 |
158 | 159 | 160 |
161 |
162 |
163 | 164 | 165 |
166 |
167 |
168 | 169 |
170 |
`; 171 | } 172 | element.innerHTML = s; 173 | } 174 | 175 | /** 176 | * Init bar 177 | */ 178 | init_bar(bar_name) { 179 | var element = document.getElementById(this.bar_id) 180 | var cfg = this.config?.bar?.[bar_name] ?? null; 181 | 182 | if (element && cfg) { 183 | var s = `
`; 184 | for (var i = 0; i < cfg.length; i++) { 185 | var bar = cfg[i]; 186 | var svg_icon = bar?.icon ?? this.default_icons[bar.type]; 187 | s += `
188 | 189 |
`; 190 | } 191 | element.innerHTML += s; 192 | } else { 193 | this.config.bar = null; // disable by removing the config 194 | } 195 | 196 | } 197 | 198 | /** 199 | * Update table and bars 200 | */ 201 | update(data) { 202 | this.update_table(data); 203 | this.update_bar('in', data); 204 | this.update_bar('out', data); 205 | } 206 | 207 | /** 208 | * Update table with given data 209 | * 210 | * For each entry the following values could be set: [disable, power, info, subline] 211 | * and anditional with wallbox option: [wallbox_pvready, wallbox_stop, wallbox_amp] 212 | * 213 | * @param data 214 | */ 215 | update_table(data) { 216 | if (!this.config.table) return; 217 | 218 | data = data || {}; 219 | 220 | for (var row of this.config.table) { 221 | var d = data?.[row.id] ?? {}; 222 | var id = row.id; 223 | var row_element = document.querySelector(`#${this.table_id} [data-row="${id}"]`); 224 | var p = (data?.[id]?.power ?? 0) * (row?.sign ?? 1); 225 | 226 | this.update_table_arrow(row_element.querySelector('[data-id="arrow"]'), p); 227 | 228 | // === Icon === 229 | row_element.querySelector('[data-id="icon"]').classList.toggle('pft-fill-error', (d?.error ?? false)); 230 | row_element.querySelector('[data-id="icon"]').classList.toggle('pft-fill-disable', (d?.disable ?? false)); 231 | row_element.querySelector('[data-id="icon"]').classList.toggle('pft-fill-enable', !(d?.disable ?? false) && !(d?.error ?? false)); 232 | 233 | // === Text === 234 | row_element.querySelector('[data-id="power"]').textContent = (isNaN(d.power)) ? '--- W' : Math.abs(d.power) + ' W'; 235 | row_element.querySelector('[data-id="info"]').textContent = d?.info ?? ''; 236 | row_element.querySelector('[data-id="subline"]').textContent = d?.subline ?? ''; 237 | 238 | // === Wallbox === 239 | if (d?.wallbox_pvready === true) { 240 | row_element.querySelector('[data-id="wallbox-sun"]').setAttribute('visibility', 'visible'); 241 | row_element.querySelector('[data-id="wallbox-cloud"]').setAttribute('visibility', 'hidden'); 242 | } else if (d?.wallbox_pvready === false) { 243 | row_element.querySelector('[data-id="wallbox-sun"]').setAttribute('visibility', 'hidden'); 244 | row_element.querySelector('[data-id="wallbox-cloud"]').setAttribute('visibility', 'visible'); 245 | } 246 | 247 | var ele = row_element.querySelector('[data-id="wallbox-amp"]'); 248 | if (ele) { 249 | ele.classList.toggle('pft-wallbox-off', (d?.wallbox_stop === true)); 250 | ele.classList.toggle('pft-wallbox-on', (d?.wallbox_stop === false)); 251 | ele.textContent = d?.wallbox_amp ?? ''; 252 | } 253 | } 254 | } 255 | 256 | /** 257 | * Update single arrow for a table row. 258 | * 259 | * @param element HTML Element 260 | * @param value Power value in watt 261 | */ 262 | update_table_arrow(element, value) { 263 | value = (value / this.arrow_power_scale) * 100; // power for 100% 264 | 265 | var sign = (value > 0) ? -1 : 1; 266 | if (value > 100) value = 100; 267 | if (value < -100) value = -100; 268 | var line_width = this.scale_value(Math.abs(value), 5, 20); 269 | 270 | // console.log("arrow_update", sid, value, invert); 271 | 272 | if (value) { 273 | var d = this.scale_value(Math.abs(value), 10, 50 - line_width) * sign; 274 | element.setAttribute('d', `M ${-d / 2} ${-d} L ${d / 2} 0 L ${-d / 2} ${+d}`); 275 | element.setAttribute('stroke-width', line_width); 276 | } else { 277 | element.setAttribute('d', ''); 278 | } 279 | } 280 | 281 | /** 282 | * Scale a 0..100% variable between two borders 283 | * 284 | * @param value 285 | * @param min 286 | * @param max 287 | * @returns value [min..max] 288 | */ 289 | scale_value(value, min, max) { 290 | value = ((max - min) / 100) * value + min; 291 | if (value > max) value = max; 292 | return value; 293 | } 294 | 295 | /** 296 | * Update bar 297 | * 298 | * @param bar_name in/out 299 | * @data data 300 | */ 301 | update_bar(bar_name, data) { 302 | var n, i, p; 303 | var bar; 304 | var cfg = this.config?.bar?.[bar_name] ?? null; 305 | 306 | if (!cfg) return; 307 | 308 | var sum = 0; 309 | 310 | for (i = 0; i < cfg.length; i++) { 311 | bar = cfg[i]; 312 | p = parseInt(data?.[bar.id]?.power ?? 0) * (bar?.sign ?? 1); 313 | sum += Math.max(0, p); 314 | } 315 | 316 | var percentage; 317 | var percentage_sum = 0; 318 | 319 | var element = document.querySelector(`#${this.bar_id} [data-bar=${bar_name}]`); 320 | 321 | // console.log("!!!", element); 322 | 323 | for (i = 0; i < cfg.length; i++) { 324 | bar = cfg[i]; 325 | p = parseInt(data?.[bar.id]?.power ?? 0) * (bar?.sign ?? 1); 326 | p = Math.max(0, p); 327 | 328 | if (i < cfg.length - 1) { 329 | percentage = Math.round((p / sum) * 100); 330 | percentage_sum += percentage; 331 | } else 332 | percentage = 100 - percentage_sum; 333 | 334 | // console.log("update_bar", n, i, sum, bar, p, percentage); 335 | element.querySelector('[data-id="' + bar.id + '"]').style.width = percentage + '%'; 336 | } 337 | } 338 | 339 | /** 340 | * Init resources 341 | */ 342 | init_resource() { 343 | if (document.getElementById('pflow-resource')) return; // resource already defined 344 | var resource = ` 345 | 375 | `; 495 | document.head.innerHTML = resource + document.head.innerHTML; 496 | } 497 | } -------------------------------------------------------------------------------- /www/trace-charge.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | CHARGE 9 | 10 | 11 | 12 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /www/trace-feed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | FEED 9 | 10 | 11 | 12 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /www/utils.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | /** 6 | * Check if a variable is a number. 7 | * 8 | * @param value 9 | * @returns {boolean} 10 | */ 11 | function is_number(value) { 12 | return ((typeof (value) === 'number')); 13 | } 14 | 15 | 16 | /** 17 | * Fetch JSON (AJAX Request) 18 | * 19 | * @param url requested url 20 | * @param success callback for success 21 | * @param error callback for error (optional) 22 | * @param post dictionary to send as json post (optional) 23 | */ 24 | function fetch_json(url, success, error, post) { 25 | 26 | if (post) { 27 | post = { 28 | method: 'POST', 29 | body: JSON.stringify(post), 30 | headers: {'Content-Type': 'application/json'} 31 | } 32 | } 33 | 34 | fetch(url, post).then(function (response) { 35 | if (response.ok) { 36 | return response.json(); 37 | } else { 38 | return Promise.reject(response); 39 | } 40 | }).then(function (data) { 41 | success(data); 42 | }).catch(function (err) { 43 | if (error) error(err); 44 | }); 45 | } 46 | 47 | 48 | /** 49 | * Format float or integer with --- as default. 50 | * 51 | * Example usage: document.getElementById('value1').textContent = fmtNumber(data.x.y ?? null, 2) + ' A'; 52 | * @param value float or integer 53 | * @param precision number of digits displayed after the decimal point 54 | * @returns {string} 55 | */ 56 | fmtNumber = function (value, precision) { 57 | try { 58 | if (isNaN(value)) throw true; 59 | return value.toFixed(precision); 60 | } catch (e) { 61 | return (precision) ? '-.' + '-'.repeat(precision) : "---"; 62 | } 63 | }; 64 | --------------------------------------------------------------------------------