Skip to content

Commit

Permalink
Added csv_to_google_sheet.py
Browse files Browse the repository at this point in the history
  • Loading branch information
ZuinigeRijder committed Feb 5, 2023
1 parent f16acbb commit 3b6e479
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 7 deletions.
84 changes: 82 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
- [smart\_plug\_mini.cfg](#smart_plug_minicfg)
- [smart\_plug\_mini.py](#smart_plug_minipy)
- [Example standard output of smart\_plug\_mini.py](#example-standard-output-of-smart_plug_minipy)
- [csv\_to\_google\_sheet.py](#csv_to_google_sheetpy)
- [Configuration of gspread for "python csv\_to\_google\_sheet.py"](#configuration-of-gspread-for-python-csv_to_google_sheetpy)
- [Example crontab to run hourly on Raspberry Pi or another linux system](#example-crontab-to-run-hourly-on-raspberry-pi-or-another-linux-system)
- [Sniffing the e-Control App](#sniffing-the-e-control-app)
- [Month request/response](#month-requestresponse)
Expand All @@ -17,13 +19,15 @@
- [24 hour response](#24-hour-response)
- [Playing around with the server API](#playing-around-with-the-server-api)

---
# Summary

Get history kWh consumption of broadlink Smart Plug Mini (tested with model SP3S-EU) using the broadlink server and append later kWh measurements to a .csv file format per device.

I have 2 times model Smart plug SP3S-EU and tested with those.
Probably this also works for other broadlink SP mini models.

---
# Quick start

- Make sure you have installed Python 3.9 or higher. [Here is more information about installing Python](https://realpython.com/installing-python/)
Expand All @@ -32,6 +36,7 @@ Probably this also works for other broadlink SP mini models.
- Configure [smart_plug_mini.cfg](#smart_plug_minicfg), e.g. MAC addresses
- Run the [smart_plug_mini.py](#smart_plug_minipy) to generate per configured device a DEVICE_NAME.csv file from the configured datetime onwards

---
# Background

I am using 2 Smart plug SP3S-EU since 2018. The e-control App was limited in functionality/views. One of the strange things is e.g. the fact that you cannot see the month December of the year before the current year. So I was looking around if someone already had a better solution. I found the library [python-broadlink](https://github.com/mjg59/python-broadlink), but this was only for local access to your broadlink smart plug devices (get direct power measurements). So I decided to try to [sniff the e-Control app, see here](#sniffing-the-e-control-app).
Expand All @@ -41,6 +46,7 @@ In short, it appeared way easier to get the historical data of my smart plug min
So I started making [a simple standalone python script smart_plug_mini.py which appends the history data per hour in a csv file for each device](#smart_plug_minipy) and made [some parts configurable](#smart_plug_minicfg).
Also the tool writes the Day, Weeks, Months and Years summaries to separate .csv files per device.

---
# python_broadlink_smart_plug_mini_info.py

All the credits goes to [python-broadlink library](https://github.com/mjg59/python-broadlink).
Expand All @@ -59,6 +65,7 @@ Remarks for this example output:
- the MAC addresses are not my real SP3S-EU MAC addresses (I changed them, also in the rest of the examples)
- you have to configure the MAC addresses 32:AA:31:72:63:43 and 32:AA:31:72:62:40 in [smart_plug_mini.cfg](#smart_plug_minicfg)

---
# smart_plug_mini.cfg

[This configuration file](https://raw.githubusercontent.com/ZuinigeRijder/python-broadlink-smart-plug-mini/main/smart_plug_mini.cfg) needs to be configured once for the smart_plug_mini.py script.
Expand All @@ -83,6 +90,7 @@ Remarks to the configuration:
- start_dates: comma separated list of date per device you want to start filling the history in .csv files (note: when .csv file already exists, the start_date is ignored and last entry of csv file is taken for later measurements)
- time_filter: this is the timefilter for hourly measurements, maybe you want to play with this setting

---
# smart_plug_mini.py

Simple Python3 script retrieve (history) kWh values for the configured Smart Plug mini devices.
Expand All @@ -104,7 +112,7 @@ OUTPUTFILES (for each configured DEVICE_NAME):
- DEVICE_NAME.months.csv: months summary, [example](https://raw.githubusercontent.com/ZuinigeRijder/python-broadlink-smart-plug-mini/main/examples/Badkamer.months.csv)
- DEVICE_NAME.years.csv: years summary, [example](https://raw.githubusercontent.com/ZuinigeRijder/python-broadlink-smart-plug-mini/main/examples/Badkamer.years.csv)


---
## Example standard output of smart_plug_mini.py

```
Expand Down Expand Up @@ -177,6 +185,77 @@ Some remarks from this example:
A screenshot for [example spreadsheet Badkamer.xlsx](https://raw.githubusercontent.com/ZuinigeRijder/python-broadlink-smart-plug-mini/main/examples/Badkamer.xlsx) which has imported a larger Badkamer.csv:
- ![alt text](https://raw.githubusercontent.com/ZuinigeRijder/python-broadlink-smart-plug-mini/main/examples/Badkamer.xlsx.jpg)

---
# csv_to_google_sheet.py

Simple Python3 script to read the smart_plug_mini.py generated csv files and write a summary for each device to a separate Google spreadsheet.

Note: you need to install the package gspread and configure gspread, [see here for the configuration](#configuration-of-gspread-for-python-csv_to_google_sheetpy)

Usage:
```
python csv_to_google_sheet.py
```
INPUTFILES:
- smart_plug_mini.cfg
- for each configured DEVICE_NAME:
- - DEVICE_NAME.csv
- - DEVICE_NAME.days.csv
- - DEVICE_NAME.weeks.csv
- - DEVICE_NAME.months.csv
- - DEVICE_NAME.years.csv

Standard output:
- progress of what is done

OUTPUT SPREADSHEET:
- DEVICE_NAME.SP (for each configured DEVICE_NAME)

So the smart_plug_mini.py tool runs on my Raspberry Pi server, but I want to look at the results, without having to login to my server. So another tool has been made, which copies a summary to a google spreadsheet for each device: csv_to_google_sheet.py

The Google spreadsheet contains kWh usage, including nice diagrams, when you [import the example Badkamer.SP.xlsx spreadsheet in Google Spreadsheet](https://raw.githubusercontent.com/ZuinigeRijder/python-broadlink-smart-plug-mini/main/examples/Badkamer.SP.xlsx):
- last written Date, Time, kWh, Hour, Day, Week, Month, Year
- last 48 hours
- last 32 days
- last 27 weeks
- last 25 months
- last 25 years ;-)

A short video of how it can look on an Android phone, [can be found here](https://www.youtube.com/shorts/p4IWoX7yNpE).
Of course you can also view the Google Spreadsheet on your computer or tablet, e.g. Windows or Mac.

<a href="http://www.youtube.com/watch?feature=player_embedded&v=p4IWoX7yNpE" target="_blank"><img src="http://img.youtube.com/vi/p4IWoX7yNpE/0.jpg" alt="Broadlink Smart Plug Mini showing csv results in Google Spreadsheet" width="240" height="180" border="10" /></a>

---
# Configuration of gspread for "python csv_to_google_sheet.py"
For updating a Google Spreadsheet, csv_to_google_sheet.py is using the package gspread.
For Authentication with Google Spreadsheet you have to configure authentication for gspread.
This [authentication configuration is described here](https://docs.gspread.org/en/latest/oauth2.html)

The csv_to_google_sheet.py script uses access to the Google spreadsheets on behalf of a bot account using Service Account.

Follow the steps in this link above, here is the summary of these steps:
- Enable API Access for a Project
- - Head to [Google Developers Console](https://console.developers.google.com/) and create a new project (or select the one you already have).
- - In the box labeled "Search for APIs and Services", search for "Google Drive API" and enable it.
- - In the box labeled "Search for APIs and Services", search for "Google Sheets API" and enable it
- For Bots: Using Service Account
- - Go to "APIs & Services > Credentials" and choose "Create credentials > Service account key".
- - Fill out the form
- - Click "Create" and "Done".
- - Press "Manage service accounts" above Service Accounts.
- - Press on : near recently created service account and select "Manage keys" and then click on "ADD KEY > Create new key".
- - Select JSON key type and press "Create".
- - You will automatically download a JSON file with credentials
- - Remember the path to the downloaded credentials json file. Also, in the next step you will need the value of client_email from this file.
- - Move the downloaded json file to ~/.config/gspread/service_account.json. Windows users should put this file to %APPDATA%\gspread\service_account.json.
- Setup a Google Spreasheet to be updated by csv_to_google_sheet.py (for each device one google spreadsheet)
- - In Google Spreadsheet, create an empty Google Spreadsheet with the name: DEVICE_NAME.SP (or [import the example Badkamer.SP.xlsx spreadsheet in Google Spreadsheet and rename it to DEVICE_NAME.SP](https://raw.githubusercontent.com/ZuinigeRijder/python-broadlink-smart-plug-mini/main/examples/Badkamer.SP.xlsx))
- - Go to your spreadsheet and share it with the client_email from the step above (inside service_account.json)
- run "python csv_to_google_sheet.py" and if everything is correct, the DEVICE_NAME.SP will be updated with a summary of the .csv files
- configure to run "python csv_to_google_sheet.py" regularly, after having run "python smart_plug_mini.py"

---
# Example crontab to run hourly on Raspberry Pi or another linux system

Example script [run_smart_plug_mini_once.sh](https://raw.githubusercontent.com/ZuinigeRijder/python-broadlink-smart-plug-mini/main/examples/run_smart_plug_mini_once.sh) to run smart_plug_mini.py on a linux based system.
Expand All @@ -186,13 +265,14 @@ Steps:
- copy run_smart_plug_mini_once.sh, smart_plug_mini.cfg and smart_plug_mini.py in this smart_plug_mini directory
- change inside smart_plug_mini.cfg the smart_plug_mini settings
- chmod + x run_smart_plug_mini_once.sh
- optionally: add running "python csv_to_google_sheet.py" to this script

Add the following line in your crontab -e to run it once per hour 9 minutes later (crontab -e):
```
9 * * * * ~/smart_plug_mini/run_smart_plug_mini_once.sh >> ~/smart_plug_mini/run_smart_plug_mini_once.log 2>&1
```


---
# Sniffing the e-Control App

For the ones who also want to be able to sniff the calls from e-Control the App, this is how I did it (do it at your own risk):
Expand Down
180 changes: 180 additions & 0 deletions csv_to_google_sheet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# == csv_to_google_sheet.py Author: Zuinige Rijder =====================================
"""
Simple Python3 script to update google sheet with
csv input files generated by smart_plug_mini.py
"""
import configparser
import os
import traceback
import time
from datetime import datetime
from pathlib import Path
from typing import Generator
import gspread


def log(msg: str) -> None:
"""log a message prefixed with a date/time format yyyymmdd hh:mm:ss"""
print(datetime.now().strftime("%Y%m%d %H:%M:%S") + ": " + msg)


def read_reverse_order(file_name: str) -> Generator[str, None, None]:
"""read in reverse order"""
# Open file for reading in binary mode
with open(file_name, "rb") as read_obj:
# Move the cursor to the end of the file
read_obj.seek(0, os.SEEK_END)
# Get the current position of pointer i.e eof
pointer_location = read_obj.tell()
# Create a buffer to keep the last read line
buffer = bytearray()
# Loop till pointer reaches the top of the file
while pointer_location >= 0:
# Move the file pointer to the location pointed by pointer_location
read_obj.seek(pointer_location)
# Shift pointer location by -1
pointer_location = pointer_location - 1
# read that byte / character
new_byte = read_obj.read(1)
# If the read byte is newline character then one line is read
if new_byte == b"\n":
# Fetch the line from buffer and yield it
yield buffer.decode()[::-1]
# Reinitialize the byte array to save next line
buffer = bytearray()
else:
# If last read character is not eol then add it in buffer
buffer.extend(new_byte)
# If there is still data in buffer, then it is first line.
if len(buffer) > 0:
# Yield the first line too
yield buffer.decode()[::-1]


def get_last_line(filename: Path) -> str:
"""get last line of filename"""
last_line = ""
if filename.is_file():
with open(filename.name, "rb") as file:
try:
file.seek(-2, os.SEEK_END)
while file.read(1) != b"\n":
file.seek(-2, os.SEEK_CUR)
except OSError:
file.seek(0)
last_line = file.readline().decode().strip()
print(f"# last line {filename.name}: {last_line}")
return last_line


def read_csv_and_write_to_sheet(
array: list, name: str, period: str, params: tuple[str, int, int, int, int, int]
) -> list:
"""read_csv_and_write_to_sheet"""
last_line = params[0].split(",")
last_line_index = params[1]
startrow = params[2]
endrow = startrow + params[3]
strip_begin = params[4]
strip_end = params[5]

row = startrow
array.append({"range": f"A{row}:B{row}", "values": [[period, "kWh"]]})
row += 1
if len(last_line) == 7:
date_str = last_line[0].strip()[strip_begin:strip_end]
kwh = float(last_line[last_line_index].strip())
array.append({"range": f"A{row}:B{row}", "values": [[date_str, kwh]]})
row += 1
if period == "Hour":
csv_postfix = ""
else:
csv_postfix = f".{period.lower()}s"
csv_filename = Path(f"{name}{csv_postfix}.csv")
if csv_filename.is_file():
log(f"### read_reverse_csv_file: {csv_filename.name} " + "#" * 30)
for line in read_reverse_order(csv_filename.name):
line = line.strip()
if line == "":
continue
print(line)
splitted = line.split(",")
if len(splitted) > 2 and splitted[0].startswith("20"):
date_str = splitted[0].strip()[strip_begin:strip_end]
kwh = float(splitted[2].strip())
array.append({"range": f"A{row}:B{row}", "values": [[date_str, kwh]]})
row += 1

if row > endrow:
break # finished
else:
log(f"Warning: csv file {csv_filename.name} does not exist")

return array


def write_to_sheet(name: str, sheet: list) -> None:
"""write_to_sheet"""
array = []
row = 1
last = get_last_line(Path(f"{name}.csv"))
last_split = last.split(",")
if len(last_split) != 7 or not last_split[0].strip().startswith("20"):
log(f"ERROR: unexpected last line in {name}.csv: {last}")
return
header = [["Date", "Time", "kWh", "Hour", "Day", "Week", "Month", "Year"]]
array.append({"range": f"A{row}:H{row}", "values": header})
row += 1
first_line = [
[
last_split[0].strip().split(" ")[0].strip()[2:],
last_split[0].strip().split(" ")[1].strip(),
float(last_split[1].strip()),
float(last_split[2].strip()),
float(last_split[3].strip()),
float(last_split[4].strip()),
float(last_split[5].strip()),
float(last_split[6].strip()),
]
]
array.append({"range": f"A{row}:H{row}", "values": first_line})

# params(last_line, last_line_index, start, rows, strip_start, strip_end)
array = read_csv_and_write_to_sheet(array, name, "Hour", ("", 2, 3, 49, 8, 16))
array = read_csv_and_write_to_sheet(array, name, "Day", (last, 3, 54, 33, 5, 10))
array = read_csv_and_write_to_sheet(array, name, "Week", (last, 4, 89, 28, 5, 10))
array = read_csv_and_write_to_sheet(array, name, "Month", (last, 5, 119, 26, 0, 7))
array = read_csv_and_write_to_sheet(array, name, "Year", (last, 6, 147, 25, 0, 4))

sheet.batch_update(array)


def main() -> None:
"""main"""
parser = configparser.ConfigParser()
parser.read("smart_plug_mini.cfg")
sp3s_settings = dict(parser.items("smart_plug_mini"))

client = gspread.service_account()
for name in sp3s_settings["device_names"].split(","):
name = name.strip()
retries = 2
while retries > 0:
try:
spreadsheet_name = f"{name}.SP"
log(f"##### Writing {spreadsheet_name} " + "#" * 60)
spreadsheet = client.open(spreadsheet_name)
sheet = spreadsheet.sheet1
sheet.clear()
write_to_sheet(name, sheet)

retries = 0
except Exception as ex: # pylint: disable=broad-except
log("Exception: " + str(ex))
traceback.print_exc()
retries -= 1
log("Sleeping a minute")
time.sleep(60)


main()
Binary file added examples/Badkamer.SP.xlsx
Binary file not shown.
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
python_version>=3.9
broadlink==0.18.3
python_dateutil==2.8.2
gspread==5.6.2
10 changes: 5 additions & 5 deletions smart_plug_mini.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ def get_last_info_from_csv(
last_m = 0.0
last_y = 0.0

print(f"##### {csv_filename} ######################################")
log(f"##### {csv_filename} ######################################")
last_line = get_last_line(csv_filename)
if last_line != "":
splitted = last_line.split(",")
Expand All @@ -277,7 +277,7 @@ def get_last_info_from_csv(
if last_date_server > date_start_server:
last_date_server += relativedelta(hours=1) # start with next hour
date_start_server = last_date_server
print(f" {last_date_str}")
log(f" {last_date_str}")

return date_start_server, last_kwh, last_d, last_w, last_m, last_y

Expand Down Expand Up @@ -331,12 +331,12 @@ def do_kwh_counters() -> None:
delta_m,
delta_y,
) = get_last_info_from_csv(Path(f"{DEVICE_NAME}.csv"))
print(f"date_start local: {date_start_server.astimezone(ZONE_INFO_LOCAL)}")
print(f"now local : {now_server.astimezone(ZONE_INFO_LOCAL)}")
log(f"date_start local: {date_start_server.astimezone(ZONE_INFO_LOCAL)}")
log(f"now local : {now_server.astimezone(ZONE_INFO_LOCAL)}")
while date_start_server < now_server:
prev_date = date_start_server.astimezone(ZONE_INFO_LOCAL)
date_end_server = date_start_server + relativedelta(months=1)
print(
log(
f"{DEVICE_NAME}: from {date_start_server.astimezone(ZONE_INFO_LOCAL)} to {date_end_server.astimezone(ZONE_INFO_LOCAL)}" # noqa
)
result = get_kwh_counters(
Expand Down

0 comments on commit 3b6e479

Please sign in to comment.