Coverage for pygoodwe/__init__.py: 45%

262 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-15 13:36 +1000

1""" pygoodwe: a (terrible) interface to the goodwe solar API """ 

2 

3 

4from datetime import date, datetime 

5import json 

6import logging 

7import os 

8from pathlib import Path 

9import sys 

10import time 

11from typing import Any, Dict, List, Optional, Union 

12 

13import requests 

14from requests.sessions import Session 

15 

16__version__ = "0.0.17" 

17 

18POWERFLOW_STATUS_TEXT = { 

19 -1: "Outward", 

20} 

21DEFAULT_UA = "PVMaster/2.0.4 (iPhone; iOS 11.4.1; Scale/2.00)" 

22API_URL = "https://semsportal.com/api/" 

23 

24 

25class API: 

26 """API implementation""" 

27 

28 # pylint: disable=too-many-instance-attributes,too-many-arguments 

29 def __init__( 

30 self, 

31 system_id: str, 

32 account: str, 

33 password: str, 

34 api_url: str = API_URL, 

35 log_level: Optional[str] = None, 

36 user_agent: str = DEFAULT_UA, 

37 skipload: bool = False, 

38 ) -> None: 

39 """ 

40 Options: 

41 

42 skipload: don't run self.getCurrentReadings() on init 

43 api_url: you can change the API endpoint it hits 

44 """ 

45 # TODO: lang: Real Soon Now it'll filter out any responses without that language 

46 

47 if log_level is None: 

48 if "LOG_LEVEL" in os.environ: 

49 log_level = os.environ["LOG_LEVEL"] 

50 else: 

51 log_level = "INFO" 

52 

53 if log_level in ("DEBUG", "INFO", "WARNING"): 

54 log_level = getattr(logging, os.getenv("LOG_LEVEL", "INFO")) 

55 logging.basicConfig( 

56 level=log_level, 

57 ) 

58 self.session = Session() 

59 self.system_id = system_id 

60 self.account = account 

61 self.password = password 

62 self.token = '{"version":"v2.0.4","client":"ios","language":"en"}' 

63 self.global_url = api_url 

64 self.base_url = self.global_url 

65 

66 logging.debug("API URL: %s", self.base_url) 

67 

68 self.user_agent = user_agent 

69 

70 if skipload: 

71 logging.debug("Skipping initial load of data") 

72 self.data: Dict[str, Any] = {} 

73 else: 

74 logging.debug("Doing load of data") 

75 self.getCurrentReadings(raw=True) 

76 

77 def loaddata(self, filename: str) -> None: 

78 """loads a json file of existing data""" 

79 with open(filename, "r", encoding="utf8") as filehandle: 

80 self.data = json.loads(filehandle.read()) 

81 

82 _loaddata = loaddata 

83 

84 def get_current_readings( 

85 self, 

86 raw: bool = True, 

87 retry: int = 1, 

88 maxretries: int = 5, 

89 delay: int = 30, 

90 ) -> Dict[str, Any]: # pylint: disable=invalid-name 

91 """gets readings at the current point in time""" 

92 payload = {"powerStationId": self.system_id} 

93 

94 # GOODWE server 

95 self.data = self.call( 

96 "v2/PowerStation/GetMonitorDetailByPowerstationId", payload 

97 ) 

98 

99 retval = self.data 

100 

101 if not self.data.get("inverter"): 

102 if retry < maxretries: 

103 logging.error( 

104 "no inverter data, try %s, trying again in %s seconds", retry, delay 

105 ) 

106 time.sleep(delay) 

107 return self.get_current_readings( 

108 raw=raw, retry=retry + 1, maxretries=maxretries, delay=delay 

109 ) 

110 logging.error("No inverter data after %s retries, quitting.", retry) 

111 sys.exit(f"No inverter data after {retry} retries, quitting.") 

112 return retval 

113 

114 # stub function names to old names 

115 getCurrentReadings = get_current_readings 

116 

117 # def getDayReadings(self, date): 

118 # date_s = date.strftime('%Y-%m-%d') 

119 # payload = { 

120 # 'powerStationId' : self.system_id 

121 # } 

122 # data = self.call("v2/PowerStation/GetMonitorDetailByPowerstationId", payload) 

123 # if 'info' not in data: 

124 # logging.warning(date_s + " - Received bad data " + str(data)) 

125 # return result 

126 # result = { 

127 # 'latitude' : data['info'].get('latitude'), 

128 # 'longitude' : data['info'].get('longitude'), 

129 # 'entries' : [] 

130 # } 

131 # payload = { 

132 # 'powerstation_id' : self.system_id, 

133 # 'count' : 1, 

134 # 'date' : date_s 

135 # } 

136 # data = self.call("PowerStationMonitor/GetPowerStationPowerAndIncomeByDay", payload) 

137 # if len(data) == 0: 

138 # logging.warning(date_s + " - Received bad data " + str(data)) 

139 # return result 

140 # eday_kwh = data[0]['p'] 

141 # payload = { 

142 # 'id' : self.system_id, 

143 # 'date' : date_s 

144 # } 

145 # data = self.call("PowerStationMonitor/GetPowerStationPacByDayForApp", payload) 

146 # if 'pacs' not in data: 

147 # logging.warning(date_s + " - Received bad data " + str(data)) 

148 # return result 

149 # minutes = 0 

150 # eday_from_power = 0 

151 # for sample in data['pacs']: 

152 # parsed_date = datetime.strptime(sample['date'], "%m/%d/%Y %H:%M:%S") 

153 # next_minutes = parsed_date.hour * 60 + parsed_date.minute 

154 # sample['minutes'] = next_minutes - minutes 

155 # minutes = next_minutes 

156 # eday_from_power += sample['pac'] * sample['minutes'] 

157 # factor = eday_kwh / eday_from_power if eday_from_power > 0 else 1 

158 # eday_kwh = 0 

159 # for sample in data['pacs']: 

160 # date += timedelta(minutes=sample['minutes']) 

161 # pgrid_w = sample['pac'] 

162 # increase = pgrid_w * sample['minutes'] * factor 

163 # if increase > 0: 

164 # eday_kwh += increase 

165 # result['entries'].append({ 

166 # 'dt' : date, 

167 # 'pgrid_w': pgrid_w, 

168 # 'eday_kwh': round(eday_kwh, 3) 

169 # }) 

170 # return result 

171 

172 @property 

173 def headers(self) -> Dict[str, str]: 

174 """request headers""" 

175 return { 

176 "User-Agent": self.user_agent, 

177 "Token": self.token, 

178 } 

179 

180 # pylint: disable=invalid-name 

181 def getDayDetailedReadingsExcel( 

182 self, 

183 export_date: date, 

184 timeout: int = 10, 

185 filename: Optional[str] = None, 

186 ) -> bool: 

187 """retrieves the detailed daily results of the given date as an Excel sheet, 

188 processing the Excel sheet is outside the scope of the current module, 

189 possible args: 

190 - filename: the path where to write the output file, default "./Plant_Power_{datestr}.xls 

191 """ 

192 datestr = datetime.strftime(export_date, "%Y-%m-%d") 

193 if filename is None: 

194 filename = f"Plant_Power_{datestr}.xls" 

195 logging.debug("Will write data for %s to file: %s", datestr, filename) 

196 

197 uri = "v1/PowerStation/ExportPowerstationPac" 

198 # {"api":"v2/PowerStation/ExportPowerstationPac","param":{"date":"2021-12-20","pw_id":"<my-pw-id>" 

199 payload_export = { 

200 "date": datestr, 

201 "pw_id": self.system_id, 

202 } 

203 

204 data = self.call(uri, payload=payload_export) 

205 

206 payload_get_url = {"id": data} 

207 get_url_uri = "v1/ReportData/GetStationPowerDataFilePath" 

208 data = self.call(get_url_uri, payload=payload_get_url) 

209 

210 file_url = data.get("file_path") 

211 if file_url is None: 

212 logging.error("Failed to get file path from ") 

213 return False 

214 

215 response = requests.get(file_url, timeout=timeout) 

216 response.raise_for_status() 

217 

218 try: 

219 file_download_path = Path(filename) 

220 file_download_path.write_bytes(response.content) 

221 except Exception as error_message: # pylint: disable=broad-except 

222 logging.error("Failed to write file %s! Error: %s", filename, error_message) 

223 return False 

224 return True 

225 

226 def do_login(self, timeout: int = 10) -> bool: 

227 """does the login and token saving thing""" 

228 login_payload = { 

229 "account": self.account, 

230 "pwd": self.password, 

231 } 

232 try: 

233 response = self.session.post( 

234 self.global_url + "v2/Common/CrossLogin", 

235 headers=self.headers, 

236 data=login_payload, 

237 timeout=timeout, 

238 ) 

239 response.raise_for_status() 

240 except requests.exceptions.RequestException as exp: 

241 logging.error("RequestException during do_login(): %s", exp) 

242 print(f"{exp=}") 

243 return False 

244 

245 data = response.json() 

246 if data.get("code") != 0: 

247 logging.error("Failed to log in: %s", data.get("msg")) 

248 print(f"{data=}") 

249 return False 

250 

251 if data.get("api"): 

252 logging.debug("Setting base url to %s", data.get("api")) 

253 self.base_url = data.get("api") 

254 self.token = json.dumps(data.get("data")) 

255 logging.debug("Done login, token: %s", self.token) 

256 return True 

257 

258 def call( 

259 self, 

260 url: str, 

261 payload: Any, 

262 max_tries: int = 3, 

263 timeout: int = 10, 

264 ) -> Dict[str, Any]: # pylint: disable=unused-argument 

265 """makes a call to the API""" 

266 for i in range(1, max_tries): 

267 try: 

268 logging.debug( 

269 "Pulling the following URL: base_url='%s', url='%s'", 

270 self.base_url, 

271 url, 

272 ) 

273 response = self.session.post( 

274 self.base_url + url, 

275 headers=self.headers, 

276 data=payload, 

277 timeout=timeout, 

278 ) 

279 response.raise_for_status() 

280 data = response.json() 

281 logging.debug("call response.json(): %s", json.dumps(data)) 

282 

283 # APIs return "success", "Success", "Successful" in the 'msg' 

284 # seen "Successful" in ExportPowerStationPac 

285 # logging.error("Msg result %s - %s", self.base_url + url, data.get('msg', '')) 

286 if ( 

287 data.get("msg", "").lower() 

288 in ( 

289 "success", 

290 "successful", 

291 ) 

292 and "data" in data 

293 ): # pylint: disable=no-else-return 

294 logging.debug( 

295 "Returning data: %s", json.dumps(data["data"], default=str) 

296 ) 

297 result: Dict[str, Any] = data.get("data") 

298 return result 

299 logging.debug(json.dumps(data)) 

300 

301 logging.debug("Logging in again...") 

302 if not self.do_login(): 

303 logging.error("Failed to log in, bailing") 

304 return {} 

305 except requests.exceptions.RequestException as exp: 

306 logging.error("RequestException: %s", exp) 

307 logging.debug("Sleeping for %s seconds...", i) 

308 time.sleep(i) 

309 

310 logging.error("Failed to call GoodWe API url='%s'", self.base_url + url) 

311 return {} 

312 

313 @classmethod 

314 def parseValue(cls, value: str, unit: str) -> float: # pylint: disable=invalid-name 

315 """takes a string value and reutrns it as a float (if possible)""" 

316 try: 

317 return float(value.rstrip(unit)) 

318 except ValueError as exp: 

319 logging.warning("ValueError: %s", exp) 

320 return 0.0 

321 

322 def are_batteries_full(self, fullstate: float = 100.0) -> bool: 

323 """boolean result for if the batteries are full. you can set your given 'full' 

324 percentage in float if you want to lower this a little 

325 are_batteries_full(fullstate=90.0): returns bool 

326 """ 

327 for battery in self.get_batteries_soc(): 

328 if battery < fullstate: 

329 return False 

330 return True 

331 

332 def _get_batteries_soc(self) -> List[float]: 

333 """returns a list of the state of charge for the batteries 

334 returns: list[float,] 

335 """ 

336 if not self.data: 

337 self.getCurrentReadings() 

338 if "inverter" not in self.data: 

339 raise ValueError("Couldn't get data...") 

340 return [ 

341 float(inverter.get("invert_full", {}).get("soc")) 

342 for inverter in self.data["inverter"] 

343 ] 

344 

345 def get_batteries_soc(self) -> List[float]: 

346 """return the battery state of charge""" 

347 return self._get_batteries_soc() 

348 

349 def getPVFlow(self) -> float: # pylint: disable=invalid-name 

350 """PV flow data""" 

351 raise NotImplementedError("SingleInverter has this, multi does not") 

352 

353 def getVoltage(self) -> List[float]: # pylint: disable=invalid-name 

354 """returns the a list of the first AC channel voltages""" 

355 if not self.data: 

356 self.getCurrentReadings(True) 

357 if "inverter" not in self.data: 

358 raise ValueError("Couldn't get data...") 

359 return [ 

360 float(inverter.get("invert_full", {}).get("vac1")) 

361 for inverter in self.data["inverter"] 

362 ] 

363 

364 def getPmeter(self) -> float: # pylint: disable=invalid-name 

365 """gets the current line pmeter""" 

366 if not self.data: 

367 self.getCurrentReadings() 

368 return float(self.data.get("inverter", {}).get("invert_full", {}).get("pmeter")) 

369 

370 def getLoadFlow(self) -> List[float]: # pylint: disable=invalid-name 

371 """returns the list of inverter multi-unit load watts""" 

372 raise NotImplementedError("multi-unit load watts isn't implemented yet") 

373 

374 def get_inverter_temperature(self) -> List[float]: 

375 """returns the list of inverter temperatures""" 

376 if not self.data: 

377 self.get_current_readings(True) 

378 if "inverter" not in self.data: 

379 raise ValueError("Couldn't get data...") 

380 return [ 

381 float(inverter.get("invert_full", {}).get("tempperature")) 

382 for inverter in self.data["inverter"] 

383 ] 

384 

385 def getDataPvoutput( 

386 self, 

387 ) -> Dict[str, Union[str, float]]: # pylint: disable=invalid-name 

388 """updates and returns the data necessary for a one-shot pvoutput upload 

389 'd' : testdate.strftime("%Y%m%d"), 

390 't' : testtime.strftime("%H:%M"), 

391 'v2' : 500, # power generation 

392 'v4' : 450, 

393 'v5' : 23.5, # temperature 

394 'v6' : 234.0, # voltage 

395 """ 

396 if not self.data: 

397 self.getCurrentReadings() 

398 # "time": "10/04/2019 14:37:29" 

399 timestamp = datetime.strptime( 

400 self.data.get("info", {}).get("time"), "%m/%d/%Y %H:%M:%S" 

401 ) 

402 data: Dict[str, Union[str, float]] = {} 

403 data["d"] = timestamp.strftime("%Y%m%d") # date 

404 data["t"] = timestamp.strftime("%H:%M") # time 

405 data["v2"] = self.getPVFlow() # PV Generation 

406 data["v4"] = self.getLoadFlow()[0] # power consumption 

407 data["v5"] = self.get_inverter_temperature()[0] # inverter temperature 

408 data["v6"] = self.getVoltage()[0] # voltage 

409 return data 

410 

411 

412class SingleInverter(API): 

413 """API implementation for an account with a single inverter""" 

414 

415 # pylint: disable=too-many-arguments 

416 def __init__( 

417 self, 

418 system_id: str, 

419 account: str, 

420 password: str, 

421 api_url: str = API_URL, 

422 log_level: Optional[str] = None, 

423 user_agent: str = DEFAULT_UA, 

424 skipload: bool = False, 

425 ) -> None: 

426 self.loadflow = 0.0 

427 self.loadflow_direction = "" 

428 

429 self.data: Dict[str, Any] 

430 

431 # instantiate the base class 

432 super().__init__( 

433 system_id, account, password, api_url, log_level, user_agent, skipload 

434 ) 

435 

436 def loaddata(self, filename: str) -> None: 

437 """loads the ata from a given file""" 

438 self._loaddata(filename) 

439 if self.data.get("inverter"): 

440 self.data["inverter"] = self.data["inverter"][0] 

441 

442 def get_current_readings( 

443 self, 

444 raw: bool = True, 

445 retry: int = 1, 

446 maxretries: int = 5, 

447 delay: int = 30, 

448 ) -> Any: 

449 """grabs the data and makes sure self.data only has a single inverter""" 

450 

451 # update the data 

452 super().get_current_readings( 

453 raw=raw, retry=retry, maxretries=maxretries, delay=delay 

454 ) 

455 

456 # reduce self.data['inverter'] to a single dict from a list 

457 self.data["inverter"] = self.data["inverter"][0] 

458 

459 return self.data 

460 

461 getCurrentReadings = get_current_readings 

462 

463 def _get_station_location(self) -> Dict[str, Union[str, int]]: 

464 """gets the identified lat and long from the station data""" 

465 return self.get_station_location() 

466 

467 def get_station_location(self) -> Dict[str, Union[str, int]]: 

468 """gets the identified lat and long from the station data""" 

469 if not self.data: 

470 self.getCurrentReadings() 

471 return { 

472 "latitude": self.data.get("info", {}).get("latitude"), 

473 "longitude": self.data.get("info", {}).get("longitude"), 

474 } 

475 

476 def getPVFlow(self) -> float: 

477 """returns the current flow amount of the PV panels""" 

478 if not self.data: 

479 self.getCurrentReadings() 

480 if self.data["powerflow"]["pv"].endswith("(W)"): 

481 pvflow = self.data["powerflow"]["pv"][:-3] 

482 else: 

483 pvflow = self.data["powerflow"]["pv"] 

484 return float(pvflow) 

485 

486 def getVoltage(self) -> float: # type: ignore 

487 """gets the current line voltage""" 

488 if not self.data: 

489 self.getCurrentReadings() 

490 return float(self.data["inverter"]["invert_full"]["vac1"]) 

491 

492 def get_day_income(self) -> float: 

493 """gets the current daily income""" 

494 if not self.data: 

495 self.getCurrentReadings() 

496 return float(self.data["kpi"]["day_income"]) 

497 

498 def get_total_income(self) -> float: 

499 """gets the total income""" 

500 if not self.data: 

501 self.getCurrentReadings() 

502 return float(self.data["kpi"]["total_income"]) 

503 

504 def get_total_power(self) -> float: 

505 """gets the total power generated""" 

506 if not self.data: 

507 self.getCurrentReadings() 

508 return float(self.data["kpi"]["total_power"]) 

509 

510 def get_day_power(self) -> float: 

511 """gets the total power generated""" 

512 if not self.data: 

513 self.getCurrentReadings() 

514 return float(self.data["kpi"]["power"]) 

515 

516 def getLoadFlow(self) -> float: # type: ignore 

517 if not self.data: 

518 self.getCurrentReadings() 

519 if self.data["powerflow"]["bettery"].endswith("(W)"): 

520 loadflow = float(self.data["powerflow"]["load"][:-3]) 

521 else: 

522 loadflow = float(self.data["powerflow"]["load"]) 

523 # I'd love to see the *house* generate power 

524 if self.data["powerflow"]["loadStatus"] == -1: 

525 loadflow_direction = "Importing" 

526 elif self.data["powerflow"]["loadStatus"] == 1: 

527 loadflow_direction = "Using Battery" 

528 else: 

529 raise ValueError( 

530 f"Your 'load' is doing something odd - status is '{self.data['powerflow']['loadStatus']}''." 

531 ) # pylint: disable=line-too-long 

532 self.loadflow = loadflow 

533 self.loadflow_direction = loadflow_direction 

534 return loadflow 

535 

536 def _get_batteries_soc(self) -> float: # type: ignore 

537 """returns the state of charge of the battery""" 

538 if not self.data: 

539 self.getCurrentReadings() 

540 if not self.data.get("soc", False): 

541 raise ValueError("No state of charge available from data") 

542 return float(self.data["soc"].get("power")) 

543 

544 def get_battery_soc(self) -> float: 

545 """returns the single value state of charge for the batteries in the plant 

546 returns : float 

547 """ 

548 return self._get_batteries_soc() 

549 

550 def get_inverter_temperature(self) -> float: # type: ignore 

551 if not self.data: 

552 self.get_current_readings(True) 

553 return float(self.data["inverter"]["tempperature"]) 

554 

555 def getDataPvoutput( 

556 self, 

557 ) -> Dict[str, Union[str, float]]: # pylint: disable=invalid-name 

558 """updates and returns the data necessary for a one-shot pvoutput upload 

559 'd' : testdate.strftime("%Y%m%d"), 

560 't' : testtime.strftime("%H:%M"), 

561 'v2' : 500, # power generation 

562 'v4' : 450, 

563 'v5' : 23.5, # temperature 

564 'v6' : 234.0, # voltage 

565 """ 

566 if not self.data: 

567 self.getCurrentReadings() 

568 # "time": "10/04/2019 14:37:29" 

569 timestamp = datetime.strptime( 

570 self.data.get("info", {}).get("time"), "%m/%d/%Y %H:%M:%S" 

571 ) 

572 data: Dict[str, Union[str, float]] = {} 

573 data["d"] = timestamp.strftime("%Y%m%d") # date 

574 data["t"] = timestamp.strftime("%H:%M") # time 

575 data["v2"] = self.getPVFlow() # PV Generation 

576 data["v4"] = self.getLoadFlow() # power consumption 

577 data["v5"] = self.get_inverter_temperature() # inverter temperature 

578 data["v6"] = self.getVoltage() # voltage 

579 return data