.
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # China Southern Power Grid Statistics
2 |
3 | # 南方电网电费数据HA集成
4 |
5 | [](https://github.com/hacs/integration)
6 | [](https://github.com/CubicPill/china_southern_power_grid_stat/releases)
7 | [](https://www.gnu.org/licenses/gpl-3.0)
8 |
9 | ## 支持功能
10 |
11 | - ✅支持南方电网覆盖范围内的电费数据查询(广东、广西、云南、贵州、海南)
12 | - ✅支持使用手机号、短信验证码和密码(可选)登录,支持南网在线APP、微信、支付宝扫码登录
13 | - ✅支持多个南网账户(每个账户一个集成),支持单个账户下的多个缴费号
14 | - ✅数据自动抓取和更新(默认间隔4小时,可配置)
15 | - ✅全程GUI配置,无需编辑yaml进行配置(暂不支持yaml配置)
16 |
17 | 可接入如下数据:
18 |
19 | - 当前余额和欠费
20 | - 当前阶梯电量数据(档位、阶梯剩余电量、阶梯电价)
21 | - 昨日用电量
22 | - 最新一日用电量、电费(取有数据的最近一日)
23 | - 本年度总用电量、总电费(非实时,更新到上个月)
24 | - 本年度每月用电量、电费(非实时,更新到上个月)
25 | - 上年度总用电量、总电费
26 | - 上年度每月用电量、电费
27 | - 当月累计用电量、电费(非实时,有2天左右的延迟)
28 | - 当月每日用电量、电费(非实时,有2天左右的延迟)
29 | - 上月累计用电量、电费
30 | - 上月每日用电量、电费
31 |
32 | ❌**不支持**阶梯电费设置(仅能获取当前所在阶梯)、峰谷电价设置和电费计算(本插件只进行数据抓取和转换,不进行任何计算),
33 | 暂时也没有支持计划(南网暂时没有统一的API),如有需求,建议单独创建对应的电价实体。
34 |
35 | ❌因为南网登录API调整,不再支持登录态失效之后自动重新登录,需要手动重新登录。
36 | ## 使用方法
37 |
38 | 使用[HACS](https://hacs.xyz/)或[手动下载安装](https://github.com/CubicPill/china_southern_power_grid_stat/releases)
39 |
40 | 注意:本集成需求`Home Assistant`最低版本为`2022.11`。
41 |
42 | ### 配置界面
43 |
44 | 支持的登录方式
45 |
46 |
47 |
48 | 配置界面
49 |
50 |
51 |
52 | 添加缴费号
53 |
54 |
55 |
56 | 传感器列表
57 | - 余额
58 | - 欠费
59 | - 当前阶梯档位
60 | - 当前阶梯剩余电量
61 | - 当前阶梯电价
62 | - 上月电费
63 | - 上月用电量
64 | - 当月用电量
65 | - 当月电费
66 | - 本年度电费
67 | - 本年度用电量
68 | - 上年度电费
69 | - 上年度用电量
70 | - 最近日用电量
71 | - 最近日电费
72 | - 昨日用电量
73 |
74 |
75 |
76 |
77 | 传感器额外参数(每月用量、每日用量)
78 |
79 |
80 |
81 | 参数设置
82 |
83 |
84 |
85 | ### 数据更新策略
86 |
87 | 由于上月数据和去年数据在生成之后一般不会发生变化,因此对于上月累计用电量、上月每日用电量、上年度累计用电量、上年度每月用电量,数据更新间隔将会与一般更新间隔有所不同。
88 | 具体更新策略如下:
89 |
90 | 对于上月数据,在每月前3天(1~3日)将会跟随一般更新间隔更新(默认为4小时),其余时间将会停止更新,但数据依然可用。
91 |
92 | 对于去年数据,在每年一月的前7天(1月1日~1月7日)将会每天更新(在每天第一次触发更新时更新),其余时间将会停止更新,但数据依然可用。
93 |
94 | 如果需要强制刷新数据,重载集成即可。
95 |
96 | ## 一些技术细节
97 |
98 | ### 登录接口加密原理
99 |
100 | 登录接口的请求数据和返回数据都经过加密,其中请求数据经过两层加密:整个请求数据的`AES`加密和密码字段的`RSA`
101 | 公钥加密(密钥、公钥具体值见代码)。
102 |
103 | 加密前的请求数据结构如下:
104 |
105 | ```json5
106 | {
107 | "areaCode": "xxx",
108 | "acctId": "xxx",
109 | "logonChan": "xxx",
110 | "credType": "xxx",
111 | "credentials": "xxx" // <- encrypted with RSA
112 | }
113 | ```
114 |
115 | 返回数据同样经过`AES`加密,密钥与请求数据相同。但返回值其中暂时不包含有用信息,验证状态码正常后可以直接忽略内容。
116 |
117 | ### Web端接口和App端接口
118 |
119 | 对于南网API相关信息的提取主要通过Web端的抓包和JS代码获取。
120 | 之后因为登录态有效期问题,对App端抓包进行比对后切换到App端API。
121 | 经过验证,Web端(网上营业厅)和App端(南网在线)的API接口基本相同,差别主要在于:
122 |
123 | | | Web | App |
124 | |--------------|----------------------------|-------------------------|
125 | | API路径 | ucs/ma/wt/ | ucs/ma/zt/ |
126 | | 支持登录方式 | 手机号+验证码(+密码),南网在线/微信/支付宝扫码 | 手机号+验证码(+密码),微信/支付宝跳转登录 |
127 | | token有效期 | 几小时(有待进一步确认) | 较长(有待进一步确认) |
128 | | Cookies | token包含在cookies中 | 无cookies |
129 | | 敏感信息(姓名、地址等) | 部分信息用“*”隐去 | 有明文全文 |
130 |
131 | 另外在HTTP请求头上有细微的差别(如:UA),但实际上对于请求的返回结果没有影响。
132 |
133 | ### API 实现库
134 |
135 | 本项目代码中的[`csg_client/__init__.py`](https://github.com/CubicPill/china_southern_power_grid_stat/blob/master/custom_components/china_southern_power_grid_stat/csg_client/__init__.py)
136 | 是对南网在线 App API 的实现,可以独立于此项目单独使用。
137 | 详细使用方法见`csg_client_demo.py`
138 |
139 | ## Thank you
140 | - [lyylyylyylyy](https://github.com/lyylyylyylyy): PR [#30](https://github.com/CubicPill/china_southern_power_grid_stat/pull/30) 短信验证码登录支持
141 |
142 | 感谢[瀚思彼岸](https://bbs.hassbian.com/)论坛以下帖子作者的辛苦付出,排名不分先后
143 |
144 | - [不折腾,超简单接入电费数据](https://bbs.hassbian.com/thread-18474-1-1.html)
145 | - [北京电费查询加强版](https://bbs.hassbian.com/thread-13820-1-1.html)
146 | - [电费插件(Node-Red流)-广东南方电网](https://bbs.hassbian.com/thread-17830-1-1.html)
147 | - [【抄作业】电费插件(NR流)-南网](https://bbs.hassbian.com/thread-18122-1-1.html)
148 |
149 | 自定义集成教程参考:[Building a Home Assistant Custom Component Part 1: Project Structure and Basics](https://aarongodfrey.dev/home%20automation/building_a_home_assistant_custom_component_part_1/)
150 |
151 |
152 |
153 |
154 |
155 |
156 |
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """The China Southern Power Grid Statistics integration."""
3 | from __future__ import annotations
4 |
5 | import logging
6 | import time
7 |
8 | from homeassistant.config_entries import ConfigEntry
9 | from homeassistant.const import CONF_USERNAME, Platform
10 | from homeassistant.core import HomeAssistant
11 | from homeassistant.exceptions import ConfigEntryAuthFailed
12 | from homeassistant.helpers import entity_registry
13 | from homeassistant.helpers.device_registry import DeviceEntry
14 |
15 | from .const import (
16 | CONF_AUTH_TOKEN,
17 | CONF_ELE_ACCOUNTS,
18 | CONF_LOGIN_TYPE,
19 | CONF_UPDATED_AT,
20 | DOMAIN,
21 | )
22 | from .csg_client import (
23 | CSGAPIError,
24 | CSGClient,
25 | CSGElectricityAccount,
26 | InvalidCredentials,
27 | NotLoggedIn,
28 | )
29 | from .sensor import CSGCostSensor, CSGEnergySensor
30 |
31 | PLATFORMS: list[Platform] = [Platform.SENSOR]
32 | _LOGGER = logging.getLogger(__name__)
33 |
34 |
35 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
36 | """Set up China Southern Power Grid Statistics from a config entry."""
37 | hass.data.setdefault(DOMAIN, {})
38 |
39 | # validate session, re-authenticate if needed
40 | client = CSGClient.load(
41 | {
42 | CONF_AUTH_TOKEN: entry.data[CONF_AUTH_TOKEN],
43 | }
44 | )
45 | if not await hass.async_add_executor_job(client.verify_login):
46 | raise ConfigEntryAuthFailed("Login expired")
47 |
48 | hass.data[DOMAIN][entry.entry_id] = {}
49 |
50 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
51 |
52 | return True
53 |
54 |
55 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
56 | """Unload a config entry."""
57 | _LOGGER.debug(f"Unloading entry: {entry.title}")
58 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
59 | _LOGGER.debug(f"Unload platforms for entry: {entry.title}, success: {unload_ok}")
60 | hass.data[DOMAIN].pop(entry.entry_id)
61 | return True
62 |
63 |
64 | async def async_remove_config_entry_device(
65 | hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
66 | ) -> bool:
67 | """Remove device"""
68 | _LOGGER.info(f"removing device {device_entry.name}")
69 | account_num = list(device_entry.identifiers)[0][1]
70 |
71 | # remove entities
72 | entity_reg = entity_registry.async_get(hass)
73 | entities = {
74 | ent.unique_id: ent.entity_id
75 | for ent in entity_registry.async_entries_for_config_entry(
76 | entity_reg, config_entry.entry_id
77 | )
78 | if account_num in ent.unique_id
79 | }
80 | for entity_id in entities.values():
81 | entity_reg.async_remove(entity_id)
82 |
83 | # update config entry
84 | new_data = config_entry.data.copy()
85 | new_data[CONF_ELE_ACCOUNTS].pop(account_num)
86 | new_data[CONF_UPDATED_AT] = str(int(time.time() * 1000))
87 | hass.config_entries.async_update_entry(
88 | config_entry,
89 | data=new_data,
90 | )
91 | _LOGGER.info(
92 | "Removed ele account from %s: %s",
93 | config_entry.data[CONF_USERNAME],
94 | account_num,
95 | )
96 | return True
97 |
98 |
99 | async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
100 | """Handle removal of an entry."""
101 | _LOGGER.info("Removing entry: account %s", entry.data[CONF_USERNAME])
102 |
103 | # logout
104 | def client_logout():
105 | client = CSGClient.load(
106 | {
107 | CONF_AUTH_TOKEN: entry.data[CONF_AUTH_TOKEN],
108 | }
109 | )
110 | if client.verify_login():
111 | client.logout(entry.data[CONF_LOGIN_TYPE])
112 | _LOGGER.info("CSG account %s logged out", entry.data[CONF_USERNAME])
113 |
114 | await hass.async_add_executor_job(client_logout)
115 |
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/config_flow.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | Config flow for China Southern Power Grid Statistics integration.
4 | Steps:
5 | 1. User input account credentials (username and password), the validity of credential verified
6 | 2. Get all electricity accounts linked to the user account, let user select one of them
7 | 3. Get the rest of needed parameters and save the config entries
8 | """
9 | from __future__ import annotations
10 |
11 | import copy
12 | import logging
13 | import time
14 | from typing import Any
15 |
16 | import voluptuous as vol
17 | from homeassistant import config_entries
18 | from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
19 | from homeassistant.core import callback
20 | from homeassistant.data_entry_flow import FlowResult
21 | from homeassistant.exceptions import ConfigEntryAuthFailed
22 | from requests import RequestException
23 |
24 | from .const import (
25 | ABORT_ALL_ADDED,
26 | ABORT_NO_ACCOUNT,
27 | CONF_ACCOUNT_NUMBER,
28 | CONF_ACTION,
29 | CONF_AUTH_TOKEN,
30 | CONF_ELE_ACCOUNTS,
31 | CONF_GENERAL_ERROR,
32 | CONF_LOGIN_TYPE,
33 | CONF_REFRESH_QR_CODE,
34 | CONF_SETTINGS,
35 | CONF_SMS_CODE,
36 | CONF_UPDATE_INTERVAL,
37 | CONF_UPDATED_AT,
38 | DEFAULT_UPDATE_INTERVAL,
39 | DOMAIN,
40 | ERROR_CANNOT_CONNECT,
41 | ERROR_INVALID_AUTH,
42 | ERROR_QR_NOT_SCANNED,
43 | ERROR_UNKNOWN,
44 | LOGIN_TYPE_TO_QR_APP_NAME,
45 | STEP_ADD_ACCOUNT,
46 | STEP_ALI_QR_LOGIN,
47 | STEP_CSG_QR_LOGIN,
48 | STEP_INIT,
49 | STEP_QR_LOGIN,
50 | STEP_SETTINGS,
51 | STEP_SMS_LOGIN,
52 | STEP_SMS_PWD_LOGIN,
53 | STEP_USER,
54 | STEP_VALIDATE_SMS_CODE,
55 | STEP_WX_QR_LOGIN,
56 | )
57 | from .csg_client import (
58 | LOGIN_TYPE_TO_QR_CODE_TYPE,
59 | CSGClient,
60 | CSGElectricityAccount,
61 | InvalidCredentials,
62 | LoginType,
63 | )
64 |
65 | _LOGGER = logging.getLogger(__name__)
66 |
67 |
68 | class CSGConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
69 | """Handle a config flow for China Southern Power Grid Statistics."""
70 |
71 | VERSION = 1
72 | _reauth_entry: config_entries.ConfigEntry | None = None
73 |
74 | @staticmethod
75 | @callback
76 | def async_get_options_flow(
77 | config_entry: config_entries.ConfigEntry,
78 | ) -> config_entries.OptionsFlow:
79 | """Create the options flow."""
80 | return CSGOptionsFlowHandler(config_entry)
81 |
82 | async def async_step_user(
83 | self, user_input: dict[str, Any] | None = None
84 | ) -> FlowResult:
85 | """
86 | Handle the initial step.
87 | Let user choose the login method.
88 | """
89 | self.context["user_data"] = {}
90 | return self.async_show_menu(
91 | step_id=STEP_USER,
92 | menu_options=[
93 | STEP_SMS_LOGIN,
94 | STEP_SMS_PWD_LOGIN,
95 | STEP_CSG_QR_LOGIN,
96 | STEP_WX_QR_LOGIN,
97 | STEP_ALI_QR_LOGIN,
98 | ],
99 | )
100 |
101 | async def async_step_sms_login(
102 | self, user_input: dict[str, Any] | None = None
103 | ) -> FlowResult:
104 | """Handle SMS login step."""
105 | if user_input is None:
106 | # initial step, need phone number to send SMS code
107 | return self.async_show_form(
108 | step_id=STEP_SMS_LOGIN,
109 | data_schema=vol.Schema(
110 | {
111 | # TODO hardcoded string, should be a reference to strings.json?
112 | vol.Required(CONF_USERNAME): vol.All(
113 | str, vol.Length(min=11, max=11), msg="请输入11位手机号"
114 | )
115 | }
116 | ),
117 | )
118 | self.context["user_data"][CONF_USERNAME] = user_input[CONF_USERNAME]
119 | self.context["user_data"][CONF_PASSWORD] = ""
120 | self.context["user_data"][CONF_LOGIN_TYPE] = LoginType.LOGIN_TYPE_SMS
121 | return await self.async_step_validate_sms_code()
122 |
123 | async def async_step_sms_pwd_login(
124 | self, user_input: dict[str, Any] | None = None
125 | ) -> FlowResult:
126 | """Handle SMS and password login step."""
127 | if user_input is None:
128 | return self.async_show_form(
129 | step_id=STEP_SMS_PWD_LOGIN,
130 | data_schema=vol.Schema(
131 | {
132 | vol.Required(CONF_USERNAME): vol.All(
133 | str, vol.Length(min=11, max=11), msg="请输入11位手机号"
134 | ),
135 | vol.Required(CONF_PASSWORD): vol.All(
136 | str, vol.Length(min=8, max=16), msg="请输入8-16位登陆密码"
137 | ), # as shown on CSG web login page
138 | }
139 | ),
140 | )
141 | self.context["user_data"][CONF_USERNAME] = user_input[CONF_USERNAME]
142 | self.context["user_data"][CONF_PASSWORD] = user_input[CONF_PASSWORD]
143 | self.context["user_data"][CONF_LOGIN_TYPE] = LoginType.LOGIN_TYPE_PWD_AND_SMS
144 | return await self.async_step_validate_sms_code()
145 |
146 | async def async_step_validate_sms_code(
147 | self, user_input: dict[str, Any] | None = None
148 | ) -> FlowResult:
149 | """Handle SMS code validation step, for both SMS and SMS+password login."""
150 | schema = vol.Schema(
151 | {
152 | vol.Required(CONF_SMS_CODE): vol.All(
153 | str, vol.Length(min=6, max=6), msg="请输入6位短信验证码"
154 | ),
155 | }
156 | )
157 | client: CSGClient = CSGClient()
158 | username = self.context["user_data"][CONF_USERNAME]
159 |
160 | if user_input is None:
161 | await self.check_and_set_unique_id(username)
162 | errors = {}
163 | error_detail = ""
164 | try:
165 | await self.hass.async_add_executor_job(
166 | client.api_send_login_sms, username
167 | )
168 | except RequestException:
169 | errors[CONF_GENERAL_ERROR] = ERROR_CANNOT_CONNECT
170 | except Exception as ge:
171 | _LOGGER.exception("Unexpected exception when sending sms code")
172 | errors[CONF_GENERAL_ERROR] = ERROR_UNKNOWN
173 | error_detail = str(ge)
174 | else:
175 | return self.async_show_form(
176 | step_id=STEP_VALIDATE_SMS_CODE,
177 | data_schema=schema,
178 | description_placeholders={"phone_no": username},
179 | )
180 | return self.async_show_form(
181 | step_id=STEP_VALIDATE_SMS_CODE,
182 | data_schema=schema,
183 | errors=errors,
184 | description_placeholders={"error_detail": error_detail},
185 | )
186 |
187 | # sms code is present, validate with api
188 | password = self.context["user_data"][CONF_PASSWORD]
189 | login_type: LoginType = self.context["user_data"][CONF_LOGIN_TYPE]
190 | sms_code = user_input[CONF_SMS_CODE]
191 |
192 | errors = {}
193 | error_detail = ""
194 | try:
195 | if login_type == LoginType.LOGIN_TYPE_SMS:
196 | auth_token = await self.hass.async_add_executor_job(
197 | client.api_login_with_sms_code,
198 | username,
199 | sms_code,
200 | )
201 | elif login_type == LoginType.LOGIN_TYPE_PWD_AND_SMS:
202 | auth_token = await self.hass.async_add_executor_job(
203 | client.api_login_with_password_and_sms_code,
204 | username,
205 | password,
206 | sms_code,
207 | )
208 | else:
209 | raise ValueError(
210 | f"Invalid login type for step {STEP_VALIDATE_SMS_CODE}: {login_type}"
211 | )
212 | except RequestException:
213 | errors[CONF_GENERAL_ERROR] = ERROR_CANNOT_CONNECT
214 | except InvalidCredentials as ice:
215 | errors[CONF_GENERAL_ERROR] = ERROR_INVALID_AUTH
216 | error_detail = str(ice)
217 | except Exception as ge:
218 | _LOGGER.exception("Unexpected exception during login validation")
219 | errors[CONF_GENERAL_ERROR] = ERROR_UNKNOWN
220 | error_detail = str(ge)
221 | else:
222 | return await self.create_or_update_config_entry(
223 | auth_token, login_type, password, username
224 | )
225 | return self.async_show_form(
226 | step_id=STEP_VALIDATE_SMS_CODE,
227 | data_schema=schema,
228 | errors=errors,
229 | description_placeholders={"error_detail": error_detail},
230 | )
231 |
232 | async def async_step_csg_qr_login(
233 | self, user_input: dict[str, Any] | None = None
234 | ) -> FlowResult:
235 | """CSG APP QR Login"""
236 | self.context["user_data"][CONF_LOGIN_TYPE] = LoginType.LOGIN_TYPE_CSG_QR
237 | return await self.async_step_qr_login()
238 |
239 | async def async_step_wx_qr_login(
240 | self, user_input: dict[str, Any] | None = None
241 | ) -> FlowResult:
242 | """WeChat QR Login"""
243 | self.context["user_data"][CONF_LOGIN_TYPE] = LoginType.LOGIN_TYPE_WX_QR
244 | return await self.async_step_qr_login()
245 |
246 | async def async_step_ali_qr_login(
247 | self, user_input: dict[str, Any] | None = None
248 | ) -> FlowResult:
249 | """AliPay QR Login"""
250 | self.context["user_data"][CONF_LOGIN_TYPE] = LoginType.LOGIN_TYPE_ALI_QR
251 | return await self.async_step_qr_login()
252 |
253 | async def async_step_qr_login(
254 | self, user_input: dict[str, Any] | None = None
255 | ) -> FlowResult:
256 | """Handle QR code login step."""
257 | client: CSGClient = CSGClient()
258 | if user_input is None:
259 | # create QR code
260 | login_type = self.context["user_data"][CONF_LOGIN_TYPE]
261 | login_id, image_link = await self.hass.async_add_executor_job(
262 | client.api_create_login_qr_code, LOGIN_TYPE_TO_QR_CODE_TYPE[login_type]
263 | )
264 | self.context["user_data"]["login_id"] = login_id
265 | self.context["user_data"]["image_link"] = image_link
266 | return self.async_show_form(
267 | step_id=STEP_QR_LOGIN,
268 | data_schema=vol.Schema(
269 | {vol.Required(CONF_REFRESH_QR_CODE, default=False): bool}
270 | ),
271 | description_placeholders={
272 | "description": f"使用{LOGIN_TYPE_TO_QR_APP_NAME[login_type]}扫码登录。登录完成后,点击下一步。"
273 | f'
',
274 | },
275 | )
276 | if user_input[CONF_REFRESH_QR_CODE]:
277 | return await self.async_step_qr_login()
278 | return await self.async_step_validate_qr_login()
279 |
280 | async def async_step_validate_qr_login(
281 | self, user_input: dict[str, Any] | None = None
282 | ) -> FlowResult:
283 | """Get QR scan status after user has scanned the code"""
284 | client: CSGClient = CSGClient()
285 | login_type = self.context["user_data"][CONF_LOGIN_TYPE]
286 | login_id = self.context["user_data"]["login_id"]
287 | ok, auth_token = await self.hass.async_add_executor_job(
288 | client.api_get_qr_login_status, login_id
289 | )
290 | if ok:
291 | # for QR login, use mobile number as username
292 | client.set_authentication_params(auth_token)
293 | user_info = await self.hass.async_add_executor_job(client.api_get_user_info)
294 | username = user_info["mobile"]
295 | await self.check_and_set_unique_id(username)
296 | return await self.create_or_update_config_entry(
297 | auth_token, login_type, "", username
298 | )
299 |
300 | # scan not detected, return to previous step
301 | image_link = self.context["user_data"]["image_link"]
302 | return self.async_show_form(
303 | step_id=STEP_QR_LOGIN,
304 | data_schema=vol.Schema(
305 | {vol.Required(CONF_REFRESH_QR_CODE, default=False): bool}
306 | ),
307 | errors={CONF_GENERAL_ERROR: ERROR_QR_NOT_SCANNED},
308 | # had to do this because strings.json conflicts with html tags
309 | description_placeholders={
310 | "description": f"使用{LOGIN_TYPE_TO_QR_APP_NAME[login_type]}扫码登录。登录完成后,点击下一步。
"
311 | f'
',
312 | },
313 | )
314 |
315 | async def check_and_set_unique_id(self, username: str):
316 | """set unique id for the config entry, abort if already configured"""
317 | # TODO: username (mobile) may not be the best unique id
318 | unique_id = f"CSG-{username}"
319 | await self.async_set_unique_id(unique_id)
320 | self._abort_if_unique_id_configured()
321 |
322 | async def create_or_update_config_entry(
323 | self, auth_token, login_type, password, username
324 | ) -> FlowResult:
325 | """Create or update config entry
326 | If the account is newly added, create a new entry
327 | If the account is already added (reauth), update the existing entry"""
328 | data = {
329 | CONF_USERNAME: username,
330 | CONF_PASSWORD: password,
331 | CONF_LOGIN_TYPE: login_type,
332 | CONF_AUTH_TOKEN: auth_token,
333 | CONF_ELE_ACCOUNTS: {},
334 | CONF_SETTINGS: {
335 | CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL,
336 | },
337 | CONF_UPDATED_AT: str(int(time.time() * 1000)),
338 | }
339 | # handle normal creation and reauth
340 | if self._reauth_entry:
341 | # reauth
342 | # save the old config and only update the auth related data
343 | old_config = copy.deepcopy(self._reauth_entry.data)
344 | data[CONF_ELE_ACCOUNTS] = old_config[CONF_ELE_ACCOUNTS]
345 | data[CONF_SETTINGS] = old_config[CONF_SETTINGS]
346 | self.hass.config_entries.async_update_entry(self._reauth_entry, data=data)
347 | await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
348 | self._reauth_entry = None
349 | return self.async_abort(reason="reauth_successful")
350 | # normal creation
351 | # check if account already exists
352 |
353 | return self.async_create_entry(
354 | title=f"CSG-{username}",
355 | data=data,
356 | )
357 |
358 | async def async_step_reauth(self, user_input=None):
359 | """Perform reauth upon an API authentication error."""
360 | self._reauth_entry = self.hass.config_entries.async_get_entry(
361 | self.context["entry_id"]
362 | )
363 | return await self.async_step_reauth_confirm()
364 |
365 | async def async_step_reauth_confirm(self, user_input=None):
366 | """Dialog that informs the user that reauth is required."""
367 | if user_input is None:
368 | return self.async_show_form(
369 | step_id="reauth_confirm",
370 | data_schema=vol.Schema({}),
371 | )
372 | return await self.async_step_user()
373 |
374 |
375 | class CSGOptionsFlowHandler(config_entries.OptionsFlow):
376 | """Handle options flow for China Southern Power Grid Statistics."""
377 |
378 | def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
379 | """Initialize options flow."""
380 | self.config_entry = config_entry
381 | self.all_electricity_accounts: list[CSGElectricityAccount] = []
382 |
383 | async def async_step_init(
384 | self, user_input: dict[str, Any] | None = None
385 | ) -> FlowResult:
386 | """Manage the options."""
387 |
388 | schema = vol.Schema(
389 | {
390 | vol.Required(CONF_ACTION, default=STEP_ADD_ACCOUNT): vol.In(
391 | {
392 | STEP_ADD_ACCOUNT: "添加已绑定的缴费号",
393 | STEP_SETTINGS: "参数设置",
394 | }
395 | ),
396 | }
397 | )
398 | if user_input:
399 | if user_input[CONF_ACTION] == STEP_ADD_ACCOUNT:
400 | return await self.async_step_add_account()
401 | if user_input[CONF_ACTION] == STEP_SETTINGS:
402 | return await self.async_step_settings()
403 | return self.async_show_form(step_id=STEP_INIT, data_schema=schema)
404 |
405 | async def async_step_add_account(
406 | self, user_input: dict[str, Any] | None = None
407 | ) -> FlowResult:
408 | """Select one of the electricity accounts from current account"""
409 | # account_no: f'{account_no} ({name} {addr})'
410 |
411 | all_csg_config_entries = self.hass.config_entries.async_entries(DOMAIN)
412 | # get a list of all account numbers from all config entries
413 | all_account_numbers = []
414 | for config_entry in all_csg_config_entries:
415 | all_account_numbers.extend(config_entry.data[CONF_ELE_ACCOUNTS].keys())
416 | if user_input:
417 | account_num_to_add = user_input[CONF_ACCOUNT_NUMBER]
418 | for account in self.all_electricity_accounts:
419 | if account.account_number == account_num_to_add:
420 | # store the account config in main entry instead of creating new entries
421 | new_data = self.config_entry.data.copy()
422 | new_data[CONF_ELE_ACCOUNTS][account_num_to_add] = account.dump()
423 | # this must be set or update won't be detected
424 | new_data[CONF_UPDATED_AT] = str(int(time.time() * 1000))
425 | self.hass.config_entries.async_update_entry(
426 | self.config_entry,
427 | data=new_data,
428 | )
429 | _LOGGER.info(
430 | "Added ele account to %s: %s",
431 | self.config_entry.data[CONF_USERNAME],
432 | account_num_to_add,
433 | )
434 | _LOGGER.info("Reloading entry because of new added account")
435 | await self.hass.config_entries.async_reload(
436 | self.config_entry.entry_id
437 | )
438 | return self.async_create_entry(
439 | title="",
440 | data={},
441 | )
442 | # end of handling add account
443 |
444 | # start of getting all unbound accounts
445 | client = CSGClient.load(
446 | {
447 | CONF_AUTH_TOKEN: self.config_entry.data[CONF_AUTH_TOKEN],
448 | }
449 | )
450 | logged_in = await self.hass.async_add_executor_job(client.verify_login)
451 | if not logged_in:
452 | # token expired
453 | raise ConfigEntryAuthFailed("Login expired")
454 | await self.hass.async_add_executor_job(client.initialize)
455 |
456 | accounts = await self.hass.async_add_executor_job(
457 | client.get_all_electricity_accounts
458 | )
459 | self.all_electricity_accounts = accounts
460 | if not accounts:
461 | _LOGGER.warning(
462 | "No linked ele accounts found in csg account %s",
463 | self.config_entry.data[CONF_USERNAME],
464 | )
465 | return self.async_abort(reason=ABORT_NO_ACCOUNT)
466 | selections = {}
467 | for account in accounts:
468 | if account.account_number not in all_account_numbers:
469 | # avoid adding one ele account twice
470 | selections[account.account_number] = (
471 | f"{account.account_number} ({account.user_name} {account.address})"
472 | )
473 | if not selections:
474 | _LOGGER.info(
475 | "Account %s: no ele account to add (all already added), abort",
476 | self.config_entry.data[CONF_USERNAME],
477 | )
478 | return self.async_abort(reason=ABORT_ALL_ADDED)
479 |
480 | schema = vol.Schema(
481 | {
482 | vol.Required(CONF_ACCOUNT_NUMBER): vol.In(selections),
483 | }
484 | )
485 | return self.async_show_form(
486 | step_id=STEP_ADD_ACCOUNT,
487 | data_schema=schema,
488 | )
489 |
490 | async def async_step_settings(
491 | self, user_input: dict[str, Any] | None = None
492 | ) -> FlowResult:
493 | """Settings of parameters"""
494 | update_interval = self.config_entry.data[CONF_SETTINGS][CONF_UPDATE_INTERVAL]
495 | schema = vol.Schema(
496 | {
497 | vol.Required(CONF_UPDATE_INTERVAL, default=update_interval): vol.All(
498 | int, vol.Range(min=60), msg="刷新间隔不能低于60秒"
499 | ),
500 | }
501 | )
502 | if user_input is None:
503 | return self.async_show_form(step_id=STEP_SETTINGS, data_schema=schema)
504 |
505 | new_data = self.config_entry.data.copy()
506 | new_data[CONF_SETTINGS][CONF_UPDATE_INTERVAL] = user_input[CONF_UPDATE_INTERVAL]
507 | new_data[CONF_UPDATED_AT] = str(int(time.time() * 1000))
508 | self.hass.config_entries.async_update_entry(
509 | self.config_entry,
510 | data=new_data,
511 | )
512 | return self.async_create_entry(
513 | title="",
514 | data={},
515 | )
516 |
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/const.py:
--------------------------------------------------------------------------------
1 | """Constants for the China Southern Power Grid Statistics integration."""
2 |
3 | from datetime import timedelta
4 |
5 | from .csg_client import LoginType
6 |
7 | DOMAIN = "china_southern_power_grid_stat"
8 |
9 | # config flow
10 | # main account (phone number)
11 | CONF_ACCOUNT_NUMBER = "account_number"
12 | CONF_LOGIN_TYPE = "login_type"
13 | CONF_AUTH_TOKEN = "auth_token"
14 | # electricity accounts
15 | CONF_ELE_ACCOUNTS = "accounts"
16 | CONF_UPDATE_INTERVAL = "update_interval"
17 | CONF_SETTINGS = "settings"
18 | CONF_UPDATED_AT = "updated_at"
19 | CONF_ACTION = "action"
20 | CONF_SMS_CODE = "sms_code"
21 | CONF_REFRESH_QR_CODE = "refresh_qr_code"
22 |
23 | STEP_USER = "user"
24 | STEP_SMS_LOGIN = "sms_login"
25 | STEP_SMS_PWD_LOGIN = "sms_pwd_login"
26 | STEP_VALIDATE_SMS_CODE = "validate_sms_code"
27 | STEP_CSG_QR_LOGIN = "csg_qr_login"
28 | STEP_WX_QR_LOGIN = "wx_qr_login"
29 | STEP_ALI_QR_LOGIN = "ali_qr_login"
30 | STEP_QR_LOGIN = "qr_login"
31 | STEP_VALIDATE_QR_LOGIN = "validate_qr_login"
32 | STEP_INIT = "init"
33 | STEP_SETTINGS = "settings"
34 | STEP_ADD_ACCOUNT = "add_account"
35 |
36 | ABORT_NO_ACCOUNT = "no_account"
37 | ABORT_ALL_ADDED = "all_added"
38 |
39 | CONF_GENERAL_ERROR = "base"
40 | ERROR_CANNOT_CONNECT = "cannot_connect"
41 | ERROR_INVALID_AUTH = "invalid_auth"
42 | ERROR_UNKNOWN = "unknown"
43 | ERROR_QR_NOT_SCANNED = "qr_not_scanned"
44 |
45 | # UI
46 | LOGIN_TYPE_TO_QR_APP_NAME = {
47 | LoginType.LOGIN_TYPE_CSG_QR: "南网APP",
48 | LoginType.LOGIN_TYPE_WX_QR: "微信",
49 | LoginType.LOGIN_TYPE_ALI_QR: "支付宝",
50 | }
51 |
52 | # api
53 |
54 |
55 | # sensor updates
56 | SUFFIX_BAL = "balance"
57 | SUFFIX_ARR = "arrears"
58 | SUFFIX_YESTERDAY_KWH = "yesterday_kwh"
59 | SUFFIX_LATEST_DAY_KWH = "latest_day_kwh"
60 | SUFFIX_LATEST_DAY_COST = "latest_day_cost"
61 | SUFFIX_THIS_YEAR_KWH = "this_year_total_usage"
62 | SUFFIX_THIS_YEAR_COST = "this_year_total_cost"
63 | SUFFIX_THIS_MONTH_KWH = "this_month_total_usage"
64 | SUFFIX_THIS_MONTH_COST = "this_month_total_cost"
65 | SUFFIX_CURRENT_LADDER = "current_ladder"
66 | SUFFIX_CURRENT_LADDER_REMAINING_KWH = "current_ladder_remaining_kwh"
67 | SUFFIX_CURRENT_LADDER_TARIFF = "current_ladder_tariff"
68 | SUFFIX_LAST_YEAR_KWH = "last_year_total_usage"
69 | SUFFIX_LAST_YEAR_COST = "last_year_total_cost"
70 | SUFFIX_LAST_MONTH_KWH = "last_month_total_usage"
71 | SUFFIX_LAST_MONTH_COST = "last_month_total_cost"
72 |
73 | ATTR_KEY_THIS_MONTH_BY_DAY = "this_month_by_day"
74 | ATTR_KEY_THIS_YEAR_BY_MONTH = "this_year_by_month"
75 | ATTR_KEY_LAST_MONTH_BY_DAY = "last_month_by_day"
76 | ATTR_KEY_LAST_YEAR_BY_MONTH = "last_year_by_month"
77 | ATTR_KEY_LATEST_DAY_DATE = "latest_day_date"
78 | ATTR_KEY_CURRENT_LADDER_START_DATE = "current_ladder_start_date"
79 |
80 | STATE_UPDATE_UNCHANGED = "unchanged"
81 | DATA_KEY_LAST_UPDATE_DAY = "last_update_day"
82 |
83 | # settings
84 |
85 | # currently, this timeout is for each request, user should not need to set it manually
86 | SETTING_UPDATE_TIMEOUT = 60
87 | # the first n days in a month that will get data of last month
88 | SETTING_LAST_MONTH_UPDATE_DAY_THRESHOLD = 3
89 | # the first n days in a year that will get data of last year
90 | SETTING_LAST_YEAR_UPDATE_DAY_THRESHOLD = 7
91 |
92 |
93 | # defaults
94 | DEFAULT_UPDATE_INTERVAL = timedelta(hours=4).seconds
95 |
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/csg_client/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | Implementations of CSG's Web API
4 | this library is synchronous - since the updates are not frequent (12h+)
5 | and each update only contains a few requests
6 | """
7 | from __future__ import annotations
8 |
9 | import datetime
10 | import json
11 | import logging
12 | import random
13 | import time
14 | from base64 import b64decode, b64encode
15 | from copy import copy
16 | from hashlib import md5
17 | from typing import Any
18 |
19 | import requests
20 | from Crypto.Cipher import AES, PKCS1_v1_5
21 | from Crypto.PublicKey import RSA
22 |
23 | from .const import *
24 |
25 | _LOGGER = logging.getLogger(__name__)
26 |
27 |
28 | class CSGAPIError(Exception):
29 | """Generic API errors"""
30 |
31 | def __init__(self, sta: str, msg: str | None = None) -> None:
32 | """sta: status code, msg: message"""
33 | Exception.__init__(self)
34 | self.sta = sta
35 | self.msg = msg
36 |
37 | def __str__(self):
38 | return f""
39 |
40 |
41 | class CSGHTTPError(CSGAPIError):
42 | """Unexpected HTTP status code (!=200)"""
43 |
44 | def __init__(self, code: int) -> None:
45 | CSGAPIError.__init__(self, sta=f"HTTP{code}")
46 | self.status_code = code
47 |
48 | def __str__(self) -> str:
49 | return f""
50 |
51 |
52 | class InvalidCredentials(CSGAPIError):
53 | """Wrong username+password combination (RESP_STA_LOGIN_WRONG_CREDENTIAL)"""
54 |
55 | def __str__(self):
56 | return f""
57 |
58 |
59 | class NotLoggedIn(CSGAPIError):
60 | """Not logged in or login expired (RESP_STA_NO_LOGIN)"""
61 |
62 | def __str__(self):
63 | return f""
64 |
65 |
66 | class QrCodeExpired(Exception):
67 | """QR code has expired"""
68 |
69 |
70 | def generate_qr_login_id():
71 | """
72 | Generate a unique id for qr code login
73 | word-by-word copied from js code
74 | """
75 | rand_str = f"{int(time.time() * 1000)}{random.random()}"
76 | return md5(rand_str.encode()).hexdigest()
77 |
78 |
79 | def encrypt_credential(password: str) -> str:
80 | """Use RSA+pubkey to encrypt password"""
81 | rsa_key = RSA.import_key(b64decode(CREDENTIAL_PUBKEY))
82 | credential_cipher = PKCS1_v1_5.new(rsa_key)
83 | encrypted_pwd = credential_cipher.encrypt(password.encode("utf8"))
84 | return b64encode(encrypted_pwd).decode()
85 |
86 |
87 | def encrypt_params(params: dict) -> str:
88 | """Decrypt response message using AES with KEY, IV"""
89 | json_cipher = AES.new(PARAM_KEY, AES.MODE_CBC, PARAM_IV)
90 |
91 | def pad(content: str) -> str:
92 | return content + (16 - len(content) % 16) * "\x00"
93 |
94 | json_str = json.dumps(params, ensure_ascii=False, separators=(",", ":"))
95 | encrypted = json_cipher.encrypt(pad(json_str).encode("utf8"))
96 | return b64encode(encrypted).decode()
97 |
98 |
99 | def decrypt_params(encrypted: str) -> dict:
100 | """Encrypt request message using AES with KEY, IV"""
101 | json_cipher = AES.new(PARAM_KEY, AES.MODE_CBC, PARAM_IV)
102 | decrypted = json_cipher.decrypt(b64decode(encrypted))
103 | # remove padding
104 | params = json.loads(decrypted.decode().strip("\x00"))
105 | return params
106 |
107 |
108 | class CSGElectricityAccount:
109 | """Represents one electricity account, identified by account number (缴费号)"""
110 |
111 | def __init__(
112 | self,
113 | account_number: str | None = None,
114 | area_code: str | None = None,
115 | ele_customer_id: str | None = None,
116 | metering_point_id: str | None = None,
117 | metering_point_number: str | None = None,
118 | address: str | None = None,
119 | user_name: str | None = None,
120 | ) -> None:
121 | # the parameters are independent for each electricity account
122 |
123 | # the 16-digit billing number, as a unique identifier, not used in api for now
124 | self.account_number = account_number
125 |
126 | self.area_code = area_code
127 |
128 | # this may change on every login, alternative name in js code is `binding_id`
129 | self.ele_customer_id = ele_customer_id
130 |
131 | # in fact one account may have multiple metering points,
132 | # however for individual users there should only be one
133 | self.metering_point_id = metering_point_id
134 | self.metering_point_number = metering_point_number
135 |
136 | # for frontend display only
137 | self.address = address
138 | self.user_name = user_name
139 |
140 | def dump(self) -> dict[str, str]:
141 | """serialize this object"""
142 | return {
143 | ATTR_ACCOUNT_NUMBER: self.account_number,
144 | ATTR_AREA_CODE: self.area_code,
145 | ATTR_ELE_CUSTOMER_ID: self.ele_customer_id,
146 | ATTR_METERING_POINT_ID: self.metering_point_id,
147 | ATTR_METERING_POINT_NUMBER: self.metering_point_number,
148 | ATTR_ADDRESS: self.address,
149 | ATTR_USER_NAME: self.user_name,
150 | }
151 |
152 | @staticmethod
153 | def load(data: dict) -> CSGElectricityAccount:
154 | """deserialize this object"""
155 | for k in (
156 | ATTR_ACCOUNT_NUMBER,
157 | ATTR_AREA_CODE,
158 | ATTR_ELE_CUSTOMER_ID,
159 | ATTR_METERING_POINT_ID,
160 | ATTR_ADDRESS,
161 | ATTR_USER_NAME,
162 | ):
163 | if k not in data:
164 | raise ValueError(f"Missing key {k}")
165 | # ATTR_METERING_POINT_NUMBER is added in later version, skip check here
166 | # TODO: add ATTR_METERING_POINT_NUMBER to the check in the future
167 | account = CSGElectricityAccount(
168 | account_number=data[ATTR_ACCOUNT_NUMBER],
169 | area_code=data[ATTR_AREA_CODE],
170 | ele_customer_id=data[ATTR_ELE_CUSTOMER_ID],
171 | metering_point_id=data[ATTR_METERING_POINT_ID],
172 | metering_point_number=data.get(ATTR_METERING_POINT_NUMBER),
173 | address=data[ATTR_ADDRESS],
174 | user_name=data[ATTR_USER_NAME],
175 | )
176 | return account
177 |
178 |
179 | class CSGClient:
180 | """
181 | Implementation of APIs from CSG iOS app interface.
182 | Parameters and consts are from web app js, however, these interfaces are virtually the same
183 |
184 | Do not call any functions starts with _api unless you are certain about what you're doing
185 |
186 | How to use:
187 | First call one of the functions to login (see example code)
188 | Then call `CSGClient.initialize` *important
189 | To get all linked electricity accounts, call `get_all_electricity_accounts`
190 | Use the account objects to call the utility functions and wrapped api functions
191 | """
192 |
193 | def __init__(
194 | self,
195 | auth_token: str | None = None,
196 | ) -> None:
197 | self._session: requests.Session = requests.Session()
198 | self._common_headers = {
199 | "Host": "95598.csg.cn",
200 | "Content-Type": "application/json;charset=utf-8",
201 | "Origin": "file://",
202 | HEADER_X_AUTH_TOKEN: "",
203 | "Accept-Encoding": "gzip, deflate, br",
204 | "Connection": "keep-alive",
205 | "Accept": "application/json, text/plain, */*",
206 | "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
207 | "AppleWebKit/605.1.15 (KHTML, like Gecko)",
208 | HEADER_CUST_NUMBER: "",
209 | "Accept-Language": "zh-CN,cn;q=0.9",
210 | }
211 |
212 | self.auth_token = auth_token
213 |
214 | # identifier, need to be set in initialize()
215 | self.customer_number = None
216 |
217 | # begin internal utility functions
218 | def _make_request(
219 | self,
220 | path: str,
221 | payload: dict | None,
222 | with_auth: bool = True,
223 | method: str = "POST",
224 | custom_headers: dict | None = None,
225 | base_path: str = BASE_PATH_APP,
226 | ):
227 | """
228 | Function to make the http request to api endpoints
229 | can automatically add authentication header(s)
230 | """
231 | _LOGGER.debug(
232 | "_make_request: %s, data=%s, auth=%s, method=%s",
233 | path,
234 | payload,
235 | with_auth,
236 | method,
237 | )
238 | url = base_path + path
239 | headers = copy(self._common_headers)
240 | if custom_headers:
241 | for _k, _v in custom_headers.items():
242 | headers[_k] = _v
243 | if with_auth:
244 | headers[HEADER_X_AUTH_TOKEN] = self.auth_token
245 | headers[HEADER_CUST_NUMBER] = self.customer_number
246 | if method == "POST":
247 | response = self._session.post(url, json=payload, headers=headers)
248 | if response.status_code != 200:
249 | _LOGGER.error(
250 | "API call %s returned status code %d", path, response.status_code
251 | )
252 | raise CSGHTTPError(response.status_code)
253 |
254 | json_str = response.content.decode("utf-8", errors="ignore")
255 | json_str = json_str[json_str.find("{") : json_str.rfind("}") + 1]
256 | json_data = json.loads(json_str)
257 | response_data = json_data
258 | _LOGGER.debug(
259 | "_make_request: %s, response: %s",
260 | path,
261 | json.dumps(response_data, ensure_ascii=False),
262 | )
263 |
264 | # headers need to be returned since they may contain additional data
265 | return response.headers, response_data
266 |
267 | raise NotImplementedError()
268 |
269 | def _handle_unsuccessful_response(self, api_path: str, response_data: dict):
270 | """Handles sta=!RESP_STA_SUCCESS"""
271 | _LOGGER.debug(
272 | "Account customer number: %s, unsuccessful response while calling %s: %s",
273 | self.customer_number,
274 | api_path,
275 | response_data,
276 | )
277 |
278 | if response_data[JSON_KEY_STA] == RESP_STA_NO_LOGIN:
279 | raise NotLoggedIn(
280 | response_data[JSON_KEY_STA], response_data.get(JSON_KEY_MESSAGE)
281 | )
282 | raise CSGAPIError(
283 | response_data[JSON_KEY_STA], response_data.get(JSON_KEY_MESSAGE)
284 | )
285 |
286 | # end internal utility functions
287 |
288 | # begin raw api functions
289 | def api_send_login_sms(self, phone_no: str):
290 | """Send SMS verification code to phone_no
291 | Note this is not the function for login with SMS, it only requests to send the code
292 | """
293 | path = "center/sendMsg"
294 | payload = {
295 | JSON_KEY_AREA_CODE: AREACODE_FALLBACK,
296 | "phoneNumber": phone_no,
297 | "vcType": VERIFICATION_CODE_TYPE_LOGIN,
298 | "msgType": SEND_MSG_TYPE_VERIFICATION_CODE,
299 | }
300 | _, resp_data = self._make_request(path, payload, with_auth=False)
301 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
302 | return True
303 | self._handle_unsuccessful_response(path, resp_data)
304 |
305 | def api_create_login_qr_code(
306 | self, channel: QRCodeType, login_id: str | None = None
307 | ) -> (str, str):
308 | """Request API to create a QR code for login
309 | Returns login_id and link to QR code image
310 | """
311 | path = "center/createLoginQrcode"
312 |
313 | login_id = login_id or generate_qr_login_id()
314 | payload = {
315 | JSON_KEY_AREA_CODE: AREACODE_FALLBACK,
316 | "channel": channel,
317 | # NOTE: this spell error is intentional
318 | "lgoinId": login_id,
319 | }
320 | _, resp_data = self._make_request(
321 | path, payload, with_auth=False, base_path=BASE_PATH_WEB
322 | )
323 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
324 | return login_id, resp_data[JSON_KEY_DATA]
325 | self._handle_unsuccessful_response(path, resp_data)
326 |
327 | def api_get_qr_login_status(self, login_id: str) -> (bool, str):
328 | """Get login status of the QR code"""
329 | path = "center/getLoginInfo"
330 | payload = {
331 | JSON_KEY_AREA_CODE: AREACODE_FALLBACK,
332 | # this one is the correct spelling
333 | "loginId": login_id,
334 | }
335 | resp_header, resp_data = self._make_request(
336 | path, payload, with_auth=False, base_path=BASE_PATH_WEB
337 | )
338 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
339 | return True, resp_header[HEADER_X_AUTH_TOKEN]
340 | if resp_data[JSON_KEY_STA] == RESP_STA_QR_NOT_SCANNED:
341 | return False, ""
342 | self._handle_unsuccessful_response(path, resp_data)
343 |
344 | def api_login_with_sms_code(self, phone_no: str, sms_code: str):
345 | """Login with phone number and SMS code"""
346 | path = "center/login"
347 | payload = {
348 | JSON_KEY_AREA_CODE: AREACODE_FALLBACK,
349 | JSON_KEY_ACCT_ID: phone_no,
350 | JSON_KEY_LOGON_CHAN: LOGON_CHANNEL_HANDHELD_HALL,
351 | JSON_KEY_CRED_TYPE: LOGIN_TYPE_PHONE_CODE,
352 | JSON_KEY_SMS_CODE: sms_code,
353 | }
354 | payload = {JSON_KEY_PARAM: encrypt_params(payload)}
355 | resp_header, resp_data = self._make_request(
356 | path, payload, with_auth=False, custom_headers={"need-crypto": "true"}
357 | )
358 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
359 | return resp_header[HEADER_X_AUTH_TOKEN]
360 | self._handle_unsuccessful_response(path, resp_data)
361 |
362 | def api_login_with_password_and_sms_code(
363 | self, phone_no: str, password: str, sms_code: str
364 | ):
365 | """Login with phone number, SMS code and password"""
366 | path = "center/loginByPwdAndMsg"
367 | payload = {
368 | JSON_KEY_AREA_CODE: AREACODE_FALLBACK,
369 | JSON_KEY_ACCT_ID: phone_no,
370 | JSON_KEY_LOGON_CHAN: LOGON_CHANNEL_HANDHELD_HALL,
371 | JSON_KEY_CRED_TYPE: LOGIN_TYPE_PHONE_PWD_CODE,
372 | "credentials": encrypt_credential(password),
373 | JSON_KEY_SMS_CODE: sms_code,
374 | "checkPwd": True,
375 | }
376 | payload = {JSON_KEY_PARAM: encrypt_params(payload)}
377 | resp_header, resp_data = self._make_request(
378 | path, payload, with_auth=False, custom_headers={"need-crypto": "true"}
379 | )
380 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
381 | return resp_header[HEADER_X_AUTH_TOKEN]
382 | if resp_data[JSON_KEY_STA] == RESP_STA_LOGIN_WRONG_CREDENTIAL:
383 | raise InvalidCredentials(
384 | resp_data[JSON_KEY_STA], resp_data.get(JSON_KEY_MESSAGE)
385 | )
386 | self._handle_unsuccessful_response(path, resp_data)
387 |
388 | def api_query_authentication_result(self) -> dict[str, Any]:
389 | """Contains custNumber, used to verify login"""
390 | path = "user/queryAuthenticationResult"
391 | payload = None
392 | _, resp_data = self._make_request(path, payload)
393 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
394 | return resp_data[JSON_KEY_DATA]
395 | self._handle_unsuccessful_response(path, resp_data)
396 |
397 | def api_get_user_info(self) -> dict[str, Any]:
398 | """Get account info"""
399 | path = "user/getUserInfo"
400 | payload = None
401 | _, resp_data = self._make_request(path, payload)
402 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
403 | return resp_data[JSON_KEY_DATA]
404 | self._handle_unsuccessful_response(path, resp_data)
405 |
406 | def api_get_all_linked_electricity_accounts(self) -> list[dict[str, Any]]:
407 | """List all linked electricity accounts under this account"""
408 | path = "eleCustNumber/queryBindEleUsers"
409 | _, resp_data = self._make_request(path, {})
410 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
411 | _LOGGER.debug(
412 | "Total %d users under this account", len(resp_data[JSON_KEY_DATA])
413 | )
414 | return resp_data[JSON_KEY_DATA]
415 | self._handle_unsuccessful_response(path, resp_data)
416 |
417 | def api_get_metering_point(
418 | self,
419 | area_code: str,
420 | ele_customer_id: str,
421 | ) -> dict:
422 | """Get metering point id"""
423 | path = "charge/queryMeteringPoint"
424 | payload = {
425 | JSON_KEY_AREA_CODE: area_code,
426 | "eleCustNumberList": [
427 | {JSON_KEY_ELE_CUST_ID: ele_customer_id, JSON_KEY_AREA_CODE: area_code}
428 | ],
429 | }
430 | # custom_headers = {"funid": "100t002"}
431 | custom_headers = {}
432 | _, resp_data = self._make_request(path, payload, custom_headers=custom_headers)
433 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
434 | return resp_data[JSON_KEY_DATA]
435 | self._handle_unsuccessful_response(path, resp_data)
436 |
437 | def api_query_day_electric_by_m_point(
438 | self,
439 | year: int,
440 | month: int,
441 | area_code: str,
442 | ele_customer_id: str,
443 | metering_point_id: str,
444 | ) -> dict:
445 | """get usage(kWh) by day in the given month"""
446 | path = "charge/queryDayElectricByMPoint"
447 | payload = {
448 | JSON_KEY_AREA_CODE: area_code,
449 | JSON_KEY_ELE_CUST_ID: ele_customer_id,
450 | JSON_KEY_YEAR_MONTH: f"{year}{month:02d}",
451 | JSON_KEY_METERING_POINT_ID: metering_point_id,
452 | }
453 | # custom_headers = {"funid": "100t002"}
454 | custom_headers = {}
455 | _, resp_data = self._make_request(path, payload, custom_headers=custom_headers)
456 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
457 | return resp_data[JSON_KEY_DATA]
458 | self._handle_unsuccessful_response(path, resp_data)
459 |
460 | def api_query_day_electric_charge_by_m_point(
461 | self,
462 | year: int,
463 | month: int,
464 | area_code: str,
465 | ele_customer_id: str,
466 | metering_point_id: str,
467 | ) -> dict:
468 | """get charge by day in the given month
469 | KNOWN BUG: this api call returns the daily cost data of year_month,
470 | but the ladder data will be this month's.
471 | this api call could take a long time to return (~30s)
472 | """
473 | path = "charge/queryDayElectricChargeByMPoint"
474 | payload = {
475 | JSON_KEY_AREA_CODE: area_code,
476 | JSON_KEY_ELE_CUST_ID: ele_customer_id,
477 | JSON_KEY_YEAR_MONTH: f"{year}{month:02d}",
478 | JSON_KEY_METERING_POINT_ID: metering_point_id,
479 | }
480 | # custom_headers = {"funid": "100t002"} # TODO: what does this do? region?
481 | custom_headers = {}
482 | _, resp_data = self._make_request(path, payload, custom_headers=custom_headers)
483 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
484 | return resp_data[JSON_KEY_DATA]
485 | self._handle_unsuccessful_response(path, resp_data)
486 |
487 | def api_query_day_electric_and_temperature(
488 | self,
489 | year: int,
490 | month: int,
491 | area_code: str,
492 | ele_customer_id: str,
493 | metering_point_id: str,
494 | ) -> dict:
495 | """get power in kWh, hi/lo temperature by day in the given month"""
496 | path = "charge/queryDayElectricAndTemperature"
497 | payload = {
498 | JSON_KEY_AREA_CODE: area_code,
499 | JSON_KEY_ELE_CUST_ID: ele_customer_id,
500 | JSON_KEY_YEAR_MONTH: f"{year}{month:02d}",
501 | JSON_KEY_METERING_POINT_ID: metering_point_id,
502 | }
503 | _, resp_data = self._make_request(path, payload)
504 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
505 | return resp_data[JSON_KEY_DATA]
506 | self._handle_unsuccessful_response(path, resp_data)
507 |
508 | def api_query_electricity_calender(
509 | self,
510 | year: int,
511 | month: int,
512 | area_code: str,
513 | ele_customer_id: str,
514 | metering_point_id: str,
515 | metering_point_number: str,
516 | ) -> dict:
517 | """get power in kWh, hi/lo/avg temperature by day in the given month"""
518 | path = "charge/queryElectricityCalendar"
519 | payload = {
520 | JSON_KEY_AREA_CODE: area_code,
521 | JSON_KEY_ELE_CUST_ID: ele_customer_id,
522 | JSON_KEY_YEAR_MONTH: f"{year}{month:02d}",
523 | JSON_KEY_METERING_POINT_ID: metering_point_id,
524 | "deviceIdentif": metering_point_number,
525 | }
526 | _, resp_data = self._make_request(path, payload)
527 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
528 | return resp_data[JSON_KEY_DATA]
529 | self._handle_unsuccessful_response(path, resp_data)
530 |
531 | def api_query_account_surplus(self, area_code: str, ele_customer_id: str):
532 | """Contains: balance and arrears"""
533 | path = "charge/queryUserAccountNumberSurplus"
534 | payload = {JSON_KEY_AREA_CODE: area_code, JSON_KEY_ELE_CUST_ID: ele_customer_id}
535 | _, resp_data = self._make_request(path, payload)
536 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
537 | return resp_data[JSON_KEY_DATA]
538 | self._handle_unsuccessful_response(path, resp_data)
539 |
540 | def api_get_fee_analyze_details(
541 | self, year: int, area_code: str, ele_customer_id: str
542 | ):
543 | """
544 | Contains: year total kWh, year total charge, kWh/charge by month in current year
545 | """
546 | path = "charge/getAnalyzeFeeDetails"
547 | payload = {
548 | JSON_KEY_AREA_CODE: area_code,
549 | "electricityBillYear": year,
550 | JSON_KEY_ELE_CUST_ID: ele_customer_id,
551 | JSON_KEY_METERING_POINT_ID: None, # this is set to null in api
552 | }
553 | _, resp_data = self._make_request(path, payload)
554 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
555 | return resp_data[JSON_KEY_DATA]
556 | self._handle_unsuccessful_response(path, resp_data)
557 |
558 | def api_query_day_electric_by_m_point_yesterday(
559 | self,
560 | area_code: str,
561 | ele_customer_id: str,
562 | ) -> dict:
563 | """Contains: power consumption(kWh) of yesterday"""
564 | path = "charge/queryDayElectricByMPointYesterday"
565 | payload = {JSON_KEY_ELE_CUST_ID: ele_customer_id, JSON_KEY_AREA_CODE: area_code}
566 | _, resp_data = self._make_request(path, payload)
567 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
568 | return resp_data[JSON_KEY_DATA]
569 | self._handle_unsuccessful_response(path, resp_data)
570 |
571 | def api_query_charges(self, area_code: str, ele_customer_id: str, _type="0"):
572 | """Contains: balance and arrears, metering points"""
573 | path = "charge/queryCharges"
574 | payload = {
575 | JSON_KEY_AREA_CODE: area_code,
576 | "eleModels": [
577 | {JSON_KEY_ELE_CUST_ID: ele_customer_id, JSON_KEY_AREA_CODE: area_code}
578 | ],
579 | "type": _type,
580 | }
581 | _, resp_data = self._make_request(path, payload)
582 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
583 | return resp_data[JSON_KEY_DATA]
584 | self._handle_unsuccessful_response(path, resp_data)
585 |
586 | def api_logout(self, logon_chan: str, cred_type: LoginType) -> None:
587 | """logout"""
588 | path = "center/logout"
589 | payload = {JSON_KEY_LOGON_CHAN: logon_chan, JSON_KEY_CRED_TYPE: cred_type}
590 | _, resp_data = self._make_request(path, payload)
591 | if resp_data[JSON_KEY_STA] == RESP_STA_SUCCESS:
592 | return resp_data[JSON_KEY_DATA]
593 | self._handle_unsuccessful_response(path, resp_data)
594 |
595 | # end raw api functions
596 |
597 | # begin utility functions
598 | @staticmethod
599 | def load(data: dict[str, str]) -> CSGClient:
600 | """
601 | Restore the session info to client object
602 | The validity of the session won't be checked
603 | `initialize()` needs to be called for the client to be usable
604 | """
605 | for k in (ATTR_AUTH_TOKEN,):
606 | if not data.get(k):
607 | raise ValueError(f"missing parameter: {k}")
608 | client = CSGClient(
609 | auth_token=data[ATTR_AUTH_TOKEN],
610 | )
611 | return client
612 |
613 | def dump(self) -> dict[str, Any]:
614 | """Dump the session to dict"""
615 | return {
616 | ATTR_AUTH_TOKEN: self.auth_token,
617 | }
618 |
619 | def set_authentication_params(self, auth_token: str):
620 | """Set self.auth_token and client generated cookies"""
621 | self.auth_token = auth_token
622 |
623 | def initialize(self):
624 | """Initialize the client"""
625 | resp_data = self.api_get_user_info()
626 | self.customer_number = resp_data[JSON_KEY_CUST_NUMBER]
627 |
628 | def verify_login(self) -> bool:
629 | """Verify validity of the session"""
630 | try:
631 | self.api_query_authentication_result()
632 | except NotLoggedIn:
633 | return False
634 | return True
635 |
636 | def logout(self, login_type: LoginType):
637 | """Logout and reset identifier, token etc."""
638 | self.api_logout(LOGON_CHANNEL_HANDHELD_HALL, login_type)
639 | self.auth_token = None
640 | self.customer_number = None
641 |
642 | # end utility functions
643 |
644 | # begin high-level api wrappers
645 |
646 | def get_all_electricity_accounts(self) -> list[CSGElectricityAccount]:
647 | """Get all electricity accounts linked to current account"""
648 | result = []
649 | ele_user_resp_data = self.api_get_all_linked_electricity_accounts()
650 |
651 | for item in ele_user_resp_data:
652 | metering_point_data = self.api_get_metering_point(
653 | item[JSON_KEY_AREA_CODE], item["bindingId"]
654 | )
655 | metering_point_id = metering_point_data[0][JSON_KEY_METERING_POINT_ID]
656 | metering_point_number = metering_point_data[0][
657 | JSON_KEY_METERING_POINT_NUMBER
658 | ]
659 | account = CSGElectricityAccount(
660 | account_number=item["eleCustNumber"],
661 | area_code=item[JSON_KEY_AREA_CODE],
662 | ele_customer_id=item["bindingId"],
663 | metering_point_id=metering_point_id,
664 | metering_point_number=metering_point_number,
665 | address=item["eleAddress"],
666 | user_name=item["userName"],
667 | )
668 | result.append(account)
669 | return result
670 |
671 | def get_month_daily_usage_detail(
672 | self, account: CSGElectricityAccount, year_month: tuple[int, int]
673 | ) -> tuple[float, list[dict[str, str | float]]]:
674 | """Get daily usage of current month"""
675 |
676 | year, month = year_month
677 |
678 | resp_data = self.api_query_day_electric_by_m_point(
679 | year,
680 | month,
681 | account.area_code,
682 | account.ele_customer_id,
683 | account.metering_point_id,
684 | )
685 | month_total_kwh = float(resp_data["totalPower"])
686 | by_day = []
687 | for d_data in resp_data["result"]:
688 | by_day.append(
689 | {WF_ATTR_DATE: d_data["date"], WF_ATTR_KWH: float(d_data["power"])}
690 | )
691 | return month_total_kwh, by_day
692 |
693 | def get_month_daily_cost_detail(
694 | self, account: CSGElectricityAccount, year_month: tuple[int, int]
695 | ) -> tuple[float | None, float | None, dict, list[dict[str, str | float]]]:
696 | """Get daily cost of current month"""
697 |
698 | year, month = year_month
699 |
700 | resp_data = self.api_query_day_electric_charge_by_m_point(
701 | year,
702 | month,
703 | account.area_code,
704 | account.ele_customer_id,
705 | account.metering_point_id,
706 | )
707 |
708 | by_day = []
709 | for d_data in resp_data["result"]:
710 | by_day.append(
711 | {
712 | WF_ATTR_DATE: d_data["date"],
713 | WF_ATTR_CHARGE: float(d_data["charge"]),
714 | WF_ATTR_KWH: float(d_data["power"]),
715 | }
716 | )
717 |
718 | # sometimes the data by day is present, but the total amount and ladder are not
719 |
720 | if resp_data["totalElectricity"] is not None:
721 | month_total_cost = float(resp_data["totalElectricity"])
722 | else:
723 | month_total_cost = None
724 |
725 | if resp_data["totalPower"] is not None:
726 | month_total_kwh = float(resp_data["totalPower"])
727 | else:
728 | month_total_kwh = None
729 |
730 | # sometimes the ladder info is null, handle that
731 | if resp_data["ladderEle"] is not None:
732 | current_ladder = int(resp_data["ladderEle"])
733 | else:
734 | current_ladder = None
735 | # "2023-05-01 00:00:00.0"
736 | if resp_data["ladderEleStartDate"] is not None:
737 | current_ladder_start_date = datetime.datetime.strptime(
738 | resp_data["ladderEleStartDate"], "%Y-%m-%d %H:%M:%S.%f"
739 | )
740 | else:
741 | current_ladder_start_date = None
742 | if resp_data["ladderEleSurplus"] is not None:
743 | current_ladder_remaining_kwh = float(resp_data["ladderEleSurplus"])
744 | else:
745 | current_ladder_remaining_kwh = None
746 | if resp_data["ladderEleTariff"] is not None:
747 | current_tariff = float(resp_data["ladderEleTariff"])
748 | else:
749 | current_tariff = None
750 | # TODO what will happen to `current_ladder_remaining_kwh` when it's the last ladder?
751 | ladder = {
752 | WF_ATTR_LADDER: current_ladder,
753 | WF_ATTR_LADDER_START_DATE: current_ladder_start_date,
754 | WF_ATTR_LADDER_REMAINING_KWH: current_ladder_remaining_kwh,
755 | WF_ATTR_LADDER_TARIFF: current_tariff,
756 | }
757 |
758 | return month_total_cost, month_total_kwh, ladder, by_day
759 |
760 | def get_balance_and_arrears(
761 | self, account: CSGElectricityAccount
762 | ) -> tuple[float, float]:
763 | """Get account balance and arrears"""
764 |
765 | resp_data = self.api_query_account_surplus(
766 | account.area_code, account.ele_customer_id
767 | )
768 | balance = resp_data[0]["balance"]
769 | arrears = resp_data[0]["arrears"]
770 | return float(balance), float(arrears)
771 |
772 | def get_year_month_stats(
773 | self, account: CSGElectricityAccount, year
774 | ) -> tuple[float, float, list[dict[str, str | float]]]:
775 | """Get year total kWh, year total charge, kWh/charge by month in current year"""
776 |
777 | resp_data = self.api_get_fee_analyze_details(
778 | year, account.area_code, account.ele_customer_id
779 | )
780 |
781 | total_year_kwh = resp_data["totalBillingElectricity"]
782 | total_year_charge = resp_data["totalActualAmount"]
783 | by_month = []
784 | for m_data in resp_data["electricAndChargeList"]:
785 | by_month.append(
786 | {
787 | WF_ATTR_MONTH: m_data[JSON_KEY_YEAR_MONTH],
788 | WF_ATTR_CHARGE: float(m_data["actualTotalAmount"]),
789 | WF_ATTR_KWH: float(m_data["billingElectricity"]),
790 | }
791 | )
792 | return float(total_year_charge), float(total_year_kwh), by_month
793 |
794 | def get_yesterday_kwh(self, account: CSGElectricityAccount) -> float:
795 | """Get power consumption(kwh) of yesterday"""
796 | resp_data = self.api_query_day_electric_by_m_point_yesterday(
797 | account.area_code, account.ele_customer_id
798 | )
799 | if resp_data["power"] is not None:
800 | return float(resp_data["power"])
801 |
802 | # end high-level api wrappers
803 |
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/csg_client/const.py:
--------------------------------------------------------------------------------
1 | """Constants for csg_client"""
2 |
3 | from enum import Enum
4 |
5 | BASE_PATH_WEB = "https://95598.csg.cn/ucs/ma/wt/"
6 | BASE_PATH_APP = "https://95598.csg.cn/ucs/ma/zt/"
7 |
8 | # https://95598.csg.cn/js/app.1.6.177.1667607288138.js
9 | PARAM_KEY = "cOdHFNHUNkZrjNaN".encode("utf8")
10 | PARAM_IV = "oMChoRLZnTivcQyR".encode("utf8")
11 | LOGON_CHANNEL_ONLINE_HALL = "3" # web
12 | LOGON_CHANNEL_HANDHELD_HALL = "4" # app
13 | RESP_STA_SUCCESS = "00"
14 | RESP_STA_EMPTY_PARAMETER = "01"
15 | RESP_STA_SYSTEM_ERROR = "02"
16 | RESP_STA_NO_LOGIN = "04"
17 | RESP_STA_QR_NOT_SCANNED = "09"
18 | SESSION_KEY_LOGIN_TYPE = "10"
19 |
20 |
21 | class LoginType(str, Enum):
22 | """Login type from JS"""
23 |
24 | LOGIN_TYPE_SMS = "11"
25 | LOGIN_TYPE_PWD_AND_SMS = "1011"
26 | LOGIN_TYPE_WX_QR = "20"
27 | LOGIN_TYPE_ALI_QR = "21"
28 | LOGIN_TYPE_CSG_QR = "30"
29 |
30 |
31 | class QRCodeType(str, Enum):
32 | """QR code type used in creation API"""
33 |
34 | QR_CSG = "app"
35 | QR_WECHAT = "wechat"
36 | QR_ALIPAY = "alipay"
37 |
38 |
39 | LOGIN_TYPE_TO_QR_CODE_TYPE = {
40 | LoginType.LOGIN_TYPE_CSG_QR: QRCodeType.QR_CSG,
41 | LoginType.LOGIN_TYPE_WX_QR: QRCodeType.QR_WECHAT,
42 | LoginType.LOGIN_TYPE_ALI_QR: QRCodeType.QR_ALIPAY,
43 | }
44 |
45 | AREACODE_FALLBACK = AREACODE_GUANGDONG = "030000"
46 |
47 | # https://95598.csg.cn/js/chunk-31aec193.1.6.177.1667607288138.js
48 | CREDENTIAL_PUBKEY = (
49 | "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQD1RJE6GBKJlFQvTU6g0ws9R"
50 | "+qXFccKl4i1Rf4KVR8Rh3XtlBtvBxEyTxnVT294RVvYz6THzHGQwREnlgdkjZyGBf7tmV2CgwaHF+ttvupuzOmRVQ"
51 | "/difIJtXKM+SM0aCOqBk0fFaLiHrZlZS4qI2/rBQN8VBoVKfGinVMM+USswwIDAQAB"
52 | )
53 |
54 | # the value of these login types are the same as the enum above
55 | # however they're not programmatically linked in the source code
56 | # use them as seperated parameters for now
57 | LOGIN_TYPE_PHONE_CODE = "11"
58 | LOGIN_TYPE_PHONE_PWD_CODE = "1011"
59 | SEND_MSG_TYPE_VERIFICATION_CODE = "1"
60 | VERIFICATION_CODE_TYPE_LOGIN = "1"
61 |
62 | # https://95598.csg.cn/js/chunk-49c87982.1.6.177.1667607288138.js
63 | RESP_STA_QR_TIMEOUT = "00010001"
64 |
65 | # from packet capture
66 | RESP_STA_LOGIN_WRONG_CREDENTIAL = "00010002"
67 |
68 | QR_EXPIRY_SECONDS = 300
69 |
70 | # account object serialisation and deserialisation
71 | ATTR_ACCOUNT_NUMBER = "account_number"
72 | ATTR_AREA_CODE = "area_code"
73 | ATTR_ELE_CUSTOMER_ID = "ele_customer_id"
74 | ATTR_METERING_POINT_ID = "metering_point_id"
75 | ATTR_METERING_POINT_NUMBER = "metering_point_number"
76 | ATTR_ADDRESS = "address"
77 | ATTR_USER_NAME = "user_name"
78 | ATTR_AUTH_TOKEN = "auth_token"
79 | ATTR_LOGIN_TYPE = "login_type"
80 |
81 | # JSON/Headers used in raw APIs
82 | HEADER_X_AUTH_TOKEN = "x-auth-token"
83 | HEADER_CUST_NUMBER = "custNumber"
84 | JSON_KEY_STA = "sta"
85 | JSON_KEY_MESSAGE = "message"
86 | JSON_KEY_CUST_NUMBER = "custNumber"
87 | JSON_KEY_DATA = "data"
88 | JSON_KEY_LOGON_CHAN = "logonChan"
89 | JSON_KEY_SMS_CODE = "code"
90 | JSON_KEY_CRED_TYPE = "credType"
91 | JSON_KEY_AREA_CODE = "areaCode"
92 | JSON_KEY_PARAM = "param"
93 | JSON_KEY_ACCT_ID = "acctId"
94 | JSON_KEY_ELE_CUST_ID = "eleCustId"
95 | JSON_KEY_METERING_POINT_ID = "meteringPointId"
96 | JSON_KEY_METERING_POINT_NUMBER = "meteringPointNumber"
97 | JSON_KEY_YEAR_MONTH = "yearMonth"
98 |
99 | # for wrapper functions
100 | WF_ATTR_LADDER = "ladder"
101 | WF_ATTR_LADDER_START_DATE = "start_date"
102 | WF_ATTR_LADDER_REMAINING_KWH = "remaining_kwh"
103 | WF_ATTR_LADDER_TARIFF = "tariff"
104 |
105 | WF_ATTR_DATE = "date"
106 | WF_ATTR_MONTH = "month"
107 | WF_ATTR_CHARGE = "charge"
108 | WF_ATTR_KWH = "kwh"
109 |
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/csg_client_demo.py:
--------------------------------------------------------------------------------
1 | # pylint: disable-all
2 | import datetime
3 | import json
4 | import os
5 | import sys
6 | import time
7 |
8 | from csg_client import (
9 | LOGIN_TYPE_TO_QR_CODE_TYPE,
10 | CSGClient,
11 | CSGElectricityAccount,
12 | LoginType,
13 | )
14 |
15 | QR_SCAN_TIMEOUT = 300
16 |
17 | # set this to False to use saved session
18 | FRESH_LOGIN = False
19 |
20 | # replace with your own credentials
21 | USERNAME = "" or os.getenv("CSG_USERNAME")
22 | PASSWORD = "" or os.getenv("CSG_PASSWORD")
23 |
24 | if __name__ == "__main__":
25 | if not os.path.isfile("session.json"):
26 | if not FRESH_LOGIN:
27 | print("错误:未找到保存的登录态,需要将FRESH_LOGIN设为True")
28 | sys.exit(1)
29 |
30 | if FRESH_LOGIN:
31 | print(
32 | "请选择登录方式:\n1. 手机号+短信验证码\n2. 手机号+短信验证码+密码\n3. 扫码登录"
33 | )
34 | login_type = None
35 | selection = input().strip()
36 | if selection == "1":
37 | login_type = LoginType.LOGIN_TYPE_SMS
38 | elif selection == "2":
39 | login_type = LoginType.LOGIN_TYPE_PWD_AND_SMS
40 | elif selection == "3":
41 | print("请选择扫码登录方式:\n1. 南网APP\n2. 微信\n3. 支付宝")
42 | qr_selection = input()
43 | if qr_selection == "1":
44 | login_type = LoginType.LOGIN_TYPE_CSG_QR
45 | elif qr_selection == "2":
46 | login_type = LoginType.LOGIN_TYPE_WX_QR
47 | elif qr_selection == "3":
48 | login_type = LoginType.LOGIN_TYPE_ALI_QR
49 | if login_type is None:
50 | print("无效选择,请重试")
51 | sys.exit(1)
52 | client = CSGClient()
53 |
54 | if login_type in [LoginType.LOGIN_TYPE_SMS, LoginType.LOGIN_TYPE_PWD_AND_SMS]:
55 | if not USERNAME or (
56 | login_type == LoginType.LOGIN_TYPE_PWD_AND_SMS and not PASSWORD
57 | ):
58 | print("错误:请填写用户名和密码,或在环境变量中设置")
59 | sys.exit(1)
60 | client.api_send_login_sms(USERNAME)
61 | print("验证码已发送,请输入验证码:")
62 | code = input().strip()
63 | if login_type == LoginType.LOGIN_TYPE_SMS:
64 | auth_token = client.api_login_with_sms_code(USERNAME, code)
65 | else:
66 | auth_token = client.api_login_with_password_and_sms_code(
67 | USERNAME, PASSWORD, code
68 | )
69 |
70 | elif login_type in [
71 | LoginType.LOGIN_TYPE_CSG_QR,
72 | LoginType.LOGIN_TYPE_WX_QR,
73 | LoginType.LOGIN_TYPE_ALI_QR,
74 | ]:
75 | login_id, qr_url = client.api_create_login_qr_code(
76 | channel=LOGIN_TYPE_TO_QR_CODE_TYPE[login_type]
77 | )
78 | print(f"请打开链接扫码登录:{qr_url}")
79 | start_time = time.time()
80 | while time.time() - start_time < QR_SCAN_TIMEOUT:
81 | ok, auth_token = client.api_get_qr_login_status(login_id)
82 | if ok:
83 | print("扫码成功!")
84 | break
85 | time.sleep(1)
86 | else:
87 | print("扫码超时,请重试")
88 | sys.exit(1)
89 | else:
90 | raise NotImplementedError(f"未知的登录类型: {login_type}")
91 |
92 | print("登录成功!")
93 | client.set_authentication_params(auth_token)
94 |
95 | else:
96 | with open("session.json", encoding="utf-8") as f:
97 | session_data = json.load(f)
98 | client = CSGClient.load(session_data)
99 |
100 | client.initialize()
101 |
102 | session = client.dump()
103 | with open("session.json", "w", encoding="utf-8") as f:
104 | json.dump(session, f)
105 | print("登录态已保存到session.json")
106 |
107 | # calling utility functions
108 |
109 | print("验证登录状态:", client.verify_login())
110 |
111 | print("用户信息:", client.api_get_user_info())
112 |
113 | accounts = client.get_all_electricity_accounts()
114 | print(f"共{len(accounts)}个绑定的电费账户")
115 |
116 | print("电费账户列表:")
117 | for i, account in enumerate(accounts):
118 | print(
119 | f"{i + 1}. {account.account_number}, {account.address}, {account.user_name}"
120 | )
121 | print("\n")
122 |
123 | account: CSGElectricityAccount = accounts[0]
124 | print(
125 | f"选取第一个账户: {account.account_number}, {account.address}, {account.user_name}"
126 | )
127 |
128 | input("按回车获取余额和欠费")
129 | bal, arr = client.get_balance_and_arrears(account)
130 | print(f"账户 {account.account_number}, 余额: {bal}, 欠费: {arr}")
131 | input("按回车获取当前月份每日用电数据")
132 | (
133 | month_total_cost,
134 | month_total_kwh,
135 | ladder,
136 | by_day,
137 | ) = client.get_month_daily_cost_detail(
138 | account, (datetime.datetime.now().year, datetime.datetime.now().month)
139 | )
140 | print(
141 | f"账户 {account.account_number}, 当月总电费: {month_total_cost}, 当月总电量: {month_total_kwh}kWh, 当前阶梯: {ladder}, 每日数据: {by_day}"
142 | )
143 |
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "domain": "china_southern_power_grid_stat",
3 | "name": "China Southern Power Grid Statistics",
4 | "codeowners": [
5 | "@cubicpill"
6 | ],
7 | "config_flow": true,
8 | "dependencies": [],
9 | "documentation": "https://github.com/CubicPill/china_southern_power_grid_stat/",
10 | "homekit": {},
11 | "iot_class": "cloud_polling",
12 | "issue_tracker": "https://github.com/CubicPill/china_southern_power_grid_stat/issues",
13 | "requirements": [
14 | "pycryptodome",
15 | "brotli"
16 | ],
17 | "ssdp": [],
18 | "version": "1.2.0",
19 | "zeroconf": []
20 | }
21 |
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/sensor.py:
--------------------------------------------------------------------------------
1 | """Sensors for the China Southern Power Grid Statistics integration."""
2 |
3 | from __future__ import annotations
4 |
5 | import asyncio
6 | import datetime
7 | import logging
8 | import time
9 | import traceback
10 | from datetime import timedelta
11 | from typing import Any
12 |
13 | import async_timeout
14 | from homeassistant.components.sensor import (
15 | SensorDeviceClass,
16 | SensorEntity,
17 | SensorStateClass,
18 | )
19 | from homeassistant.config_entries import ConfigEntry
20 | from homeassistant.const import CONF_USERNAME, STATE_UNAVAILABLE, UnitOfEnergy
21 | from homeassistant.core import HomeAssistant, callback
22 | from homeassistant.exceptions import ConfigEntryAuthFailed
23 | from homeassistant.helpers.entity import DeviceInfo
24 | from homeassistant.helpers.entity_platform import AddEntitiesCallback
25 | from homeassistant.helpers.update_coordinator import (
26 | CoordinatorEntity,
27 | DataUpdateCoordinator,
28 | )
29 |
30 | from . import CONF_UPDATED_AT
31 | from .const import (
32 | ATTR_KEY_CURRENT_LADDER_START_DATE,
33 | ATTR_KEY_LAST_MONTH_BY_DAY,
34 | ATTR_KEY_LAST_YEAR_BY_MONTH,
35 | ATTR_KEY_LATEST_DAY_DATE,
36 | ATTR_KEY_THIS_MONTH_BY_DAY,
37 | ATTR_KEY_THIS_YEAR_BY_MONTH,
38 | CONF_AUTH_TOKEN,
39 | CONF_ELE_ACCOUNTS,
40 | CONF_SETTINGS,
41 | CONF_UPDATE_INTERVAL,
42 | DATA_KEY_LAST_UPDATE_DAY,
43 | DOMAIN,
44 | SETTING_LAST_MONTH_UPDATE_DAY_THRESHOLD,
45 | SETTING_LAST_YEAR_UPDATE_DAY_THRESHOLD,
46 | SETTING_UPDATE_TIMEOUT,
47 | STATE_UPDATE_UNCHANGED,
48 | SUFFIX_ARR,
49 | SUFFIX_BAL,
50 | SUFFIX_CURRENT_LADDER,
51 | SUFFIX_CURRENT_LADDER_REMAINING_KWH,
52 | SUFFIX_CURRENT_LADDER_TARIFF,
53 | SUFFIX_LAST_MONTH_COST,
54 | SUFFIX_LAST_MONTH_KWH,
55 | SUFFIX_LAST_YEAR_COST,
56 | SUFFIX_LAST_YEAR_KWH,
57 | SUFFIX_LATEST_DAY_COST,
58 | SUFFIX_LATEST_DAY_KWH,
59 | SUFFIX_THIS_MONTH_COST,
60 | SUFFIX_THIS_MONTH_KWH,
61 | SUFFIX_THIS_YEAR_COST,
62 | SUFFIX_THIS_YEAR_KWH,
63 | SUFFIX_YESTERDAY_KWH,
64 | )
65 | from .csg_client import (
66 | JSON_KEY_METERING_POINT_NUMBER,
67 | WF_ATTR_CHARGE,
68 | WF_ATTR_DATE,
69 | WF_ATTR_KWH,
70 | WF_ATTR_LADDER,
71 | WF_ATTR_LADDER_REMAINING_KWH,
72 | WF_ATTR_LADDER_START_DATE,
73 | WF_ATTR_LADDER_TARIFF,
74 | CSGAPIError,
75 | CSGClient,
76 | CSGElectricityAccount,
77 | NotLoggedIn,
78 | )
79 |
80 | _LOGGER = logging.getLogger(__name__)
81 |
82 |
83 | async def async_setup_entry(
84 | hass: HomeAssistant,
85 | config_entry: ConfigEntry,
86 | async_add_entities: AddEntitiesCallback,
87 | ):
88 | """Setup sensors from a config entry created in the integrations UI."""
89 | if not config_entry.data[CONF_ELE_ACCOUNTS]:
90 | _LOGGER.info("No ele accounts in config, exit entry setup")
91 | return
92 | coordinator = CSGCoordinator(hass, config_entry.entry_id)
93 |
94 | all_sensors = []
95 | for ele_account_number, _ in config_entry.data[CONF_ELE_ACCOUNTS].items():
96 | sensors = [
97 | # balance
98 | CSGCostSensor(coordinator, ele_account_number, SUFFIX_BAL),
99 | # arrears
100 | CSGCostSensor(coordinator, ele_account_number, SUFFIX_ARR),
101 | # yesterday kwh
102 | CSGEnergySensor(
103 | coordinator,
104 | ele_account_number,
105 | SUFFIX_YESTERDAY_KWH,
106 | ),
107 | # latest day usage that is available, with extra attributes about the date
108 | CSGEnergySensor(
109 | coordinator,
110 | ele_account_number,
111 | SUFFIX_LATEST_DAY_KWH,
112 | extra_state_attributes_key=ATTR_KEY_LATEST_DAY_DATE,
113 | ),
114 | # latest day cost that is available, with extra attributes about the date
115 | CSGCostSensor(
116 | coordinator,
117 | ele_account_number,
118 | SUFFIX_LATEST_DAY_COST,
119 | extra_state_attributes_key=ATTR_KEY_LATEST_DAY_DATE,
120 | ),
121 | # this year's total energy, with extra attributes about monthly usage
122 | CSGEnergySensor(
123 | coordinator,
124 | ele_account_number,
125 | SUFFIX_THIS_YEAR_KWH,
126 | extra_state_attributes_key=ATTR_KEY_THIS_YEAR_BY_MONTH,
127 | ),
128 | # this year's total cost
129 | CSGCostSensor(
130 | coordinator,
131 | ele_account_number,
132 | SUFFIX_THIS_YEAR_COST,
133 | ),
134 | # this month's total energy, with extra attributes about daily usage
135 | CSGEnergySensor(
136 | coordinator,
137 | ele_account_number,
138 | SUFFIX_THIS_MONTH_KWH,
139 | extra_state_attributes_key=ATTR_KEY_THIS_MONTH_BY_DAY,
140 | ),
141 | # this month's total cost, with extra attributes about daily usage
142 | CSGCostSensor(
143 | coordinator,
144 | ele_account_number,
145 | SUFFIX_THIS_MONTH_COST,
146 | extra_state_attributes_key=ATTR_KEY_THIS_MONTH_BY_DAY,
147 | ),
148 | # current ladder, with extra attributes about start date
149 | CSGLadderStageSensor(
150 | coordinator,
151 | ele_account_number,
152 | SUFFIX_CURRENT_LADDER,
153 | extra_state_attributes_key=ATTR_KEY_CURRENT_LADDER_START_DATE,
154 | ),
155 | # current ladder remaining kwh
156 | CSGEnergySensor(
157 | coordinator, ele_account_number, SUFFIX_CURRENT_LADDER_REMAINING_KWH
158 | ),
159 | # current ladder tariff
160 | CSGCostSensor(
161 | coordinator, ele_account_number, SUFFIX_CURRENT_LADDER_TARIFF
162 | ),
163 | # last year's total energy, with extra attributes about monthly usage
164 | CSGEnergySensor(
165 | coordinator,
166 | ele_account_number,
167 | SUFFIX_LAST_YEAR_KWH,
168 | extra_state_attributes_key=ATTR_KEY_LAST_YEAR_BY_MONTH,
169 | ),
170 | # last year's total cost
171 | CSGCostSensor(
172 | coordinator,
173 | ele_account_number,
174 | SUFFIX_LAST_YEAR_COST,
175 | ),
176 | # last month's total energy, with extra attributes about daily usage
177 | CSGEnergySensor(
178 | coordinator,
179 | ele_account_number,
180 | SUFFIX_LAST_MONTH_KWH,
181 | extra_state_attributes_key=ATTR_KEY_LAST_MONTH_BY_DAY,
182 | ),
183 | # last month's total cost, with extra attributes about daily usage
184 | CSGCostSensor(
185 | coordinator,
186 | ele_account_number,
187 | SUFFIX_LAST_MONTH_COST,
188 | extra_state_attributes_key=ATTR_KEY_LAST_MONTH_BY_DAY,
189 | ),
190 | ]
191 |
192 | all_sensors.extend(sensors)
193 |
194 | async_add_entities(all_sensors)
195 | _LOGGER.debug(f"created {len(all_sensors)} sensors for config {config_entry.title}")
196 | # Schedule the first update to run in the background
197 | config_entry.async_create_task(
198 | hass,
199 | coordinator.async_config_entry_first_refresh(),
200 | f"{config_entry.title}_first_update",
201 | )
202 |
203 |
204 | class CSGBaseSensor(
205 | CoordinatorEntity,
206 | SensorEntity,
207 | ):
208 | """Base CSG sensor"""
209 |
210 | def __init__(
211 | self,
212 | coordinator: DataUpdateCoordinator,
213 | account_number: str,
214 | entity_suffix: str,
215 | extra_state_attributes_key: str | None = None,
216 | ) -> None:
217 | SensorEntity.__init__(self)
218 | CoordinatorEntity.__init__(self, coordinator)
219 | self._coordinator = coordinator
220 | self._account_number = account_number
221 |
222 | self._entity_suffix = entity_suffix
223 | self._attr_extra_state_attributes = {}
224 | self._extra_state_attributes_key = extra_state_attributes_key
225 |
226 | @property
227 | def unique_id(self) -> str | None:
228 | return f"{DOMAIN}.{self._account_number}.{self._entity_suffix}"
229 |
230 | @property
231 | def name(self) -> str | None:
232 | return f"{self._account_number}-{self._entity_suffix}"
233 |
234 | @property
235 | def should_poll(self) -> bool:
236 | return False
237 |
238 | @property
239 | def device_info(self) -> DeviceInfo:
240 | """Return the device info."""
241 | return DeviceInfo(
242 | identifiers={(DOMAIN, self._account_number)},
243 | name=f"CSGAccount-{self._account_number}",
244 | manufacturer="CSG",
245 | model="CSG Virtual Electricity Meter",
246 | )
247 |
248 | @callback
249 | def _handle_coordinator_update(self) -> None:
250 | """Handle updated data from the coordinator."""
251 | # _LOGGER.debug(
252 | # "%s coordinator update triggered",
253 | # self.unique_id,
254 | # )
255 |
256 | if not self._coordinator.data:
257 | _LOGGER.error(
258 | "%s coordinator has no data",
259 | self.unique_id,
260 | )
261 | self._attr_available = False
262 | self.async_write_ha_state()
263 | return
264 |
265 | account_data = self._coordinator.data.get(self._account_number)
266 | if account_data is None:
267 | _LOGGER.warning("%s not found in coordinator data", self.unique_id)
268 | self._attr_available = False
269 | self.async_write_ha_state()
270 | return
271 |
272 | new_native_value = account_data.get(self._entity_suffix)
273 | if new_native_value is None:
274 | _LOGGER.warning("%s data not found in coordinator data", self.unique_id)
275 | self._attr_available = False
276 | self.async_write_ha_state()
277 | return
278 |
279 | if new_native_value == STATE_UNAVAILABLE:
280 | _LOGGER.debug("%s data is unavailable", self.unique_id)
281 | self.async_write_ha_state()
282 | self._attr_available = False
283 | return
284 |
285 | # from this point the value is available
286 | self._attr_available = True
287 |
288 | if new_native_value == STATE_UPDATE_UNCHANGED:
289 | # no update for this sensor, skip
290 | _LOGGER.debug("%s doesn't need to be updated, skip", self.unique_id)
291 | return
292 |
293 | # from this point, `new_native_value` is a true value
294 | self._attr_native_value = new_native_value
295 |
296 | if self._extra_state_attributes_key:
297 | new_attributes = account_data.get(self._extra_state_attributes_key)
298 | if new_attributes is None:
299 | new_attributes = {}
300 | _LOGGER.warning(
301 | "%s attribute %s not found in coordinator data",
302 | self.unique_id,
303 | self._extra_state_attributes_key,
304 | )
305 | self._attr_extra_state_attributes = new_attributes
306 | _LOGGER.debug("%s state update done!", self.unique_id)
307 | self.async_write_ha_state()
308 |
309 |
310 | class CSGEnergySensor(CSGBaseSensor):
311 | """Representation of a CSG Energy Sensor."""
312 |
313 | _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
314 | _attr_device_class = SensorDeviceClass.ENERGY
315 | _attr_state_class = SensorStateClass.TOTAL
316 | _attr_icon = "mdi:lightning-bolt"
317 |
318 |
319 | class CSGCostSensor(CSGBaseSensor):
320 | """Representation of a CSG Cost Sensor."""
321 |
322 | _attr_native_unit_of_measurement = "CNY"
323 | _attr_device_class = SensorDeviceClass.MONETARY
324 | _attr_state_class = SensorStateClass.TOTAL
325 | _attr_icon = "mdi:currency-cny"
326 |
327 |
328 | class CSGLadderStageSensor(CSGBaseSensor):
329 | """Representation of a CSG Ladder Stage Sensor."""
330 |
331 | _attr_icon = "mdi:stairs"
332 |
333 |
334 | class CSGCoordinator(DataUpdateCoordinator):
335 | """CSG custom coordinator."""
336 |
337 | def __init__(self, hass: HomeAssistant, config_entry_id: str) -> None:
338 | """Initialize coordinator."""
339 | self._config_entry_id = config_entry_id
340 | self._config = hass.config_entries.async_get_entry(self._config_entry_id).data
341 | super().__init__(
342 | hass,
343 | _LOGGER,
344 | # Name of the data. For logging purposes.
345 | name=f"CSG Account {self._config[CONF_USERNAME]}",
346 | # Polling interval. Will only be polled if there are subscribers.
347 | update_interval=timedelta(
348 | seconds=self._config[CONF_SETTINGS][CONF_UPDATE_INTERVAL]
349 | ),
350 | )
351 | self._client: CSGClient | None = None
352 | self._if_update_last_month = True
353 | self._if_update_last_year = True
354 | self._this_day = None
355 | self._this_year = None
356 | self._this_month_ym = None
357 | self._last_year = None
358 | self._last_month_ym = None
359 | self._this_month_update_completed_flag = asyncio.Event()
360 | self._gathered_data = {}
361 |
362 | async def _async_refresh_client(self):
363 | """Refresh the client, update the user data.
364 | It cannot re-login if the session is invalidated.
365 | """
366 | _LOGGER.debug("Refreshing client")
367 | self._client = await self.hass.async_add_executor_job(
368 | CSGClient.load,
369 | {
370 | CONF_AUTH_TOKEN: self._config[CONF_AUTH_TOKEN],
371 | },
372 | )
373 | logged_in = await self.hass.async_add_executor_job(
374 | self._client.verify_login,
375 | )
376 | if not logged_in:
377 | _LOGGER.warning(f"{self._config[CONF_USERNAME]}: Login expired")
378 | raise ConfigEntryAuthFailed("Login expired")
379 |
380 | _LOGGER.debug(f"{self._config[CONF_USERNAME]}: Session still valid")
381 | await self.hass.async_add_executor_job(self._client.initialize)
382 |
383 | async def _async_fetch(self, func: callable, *args, **kwargs) -> (bool, tuple):
384 | """Wrapper to fetch data from API. Return (success, result) with timeout.
385 | Also handle all exceptions here to avoid task group being cancelled.
386 | """
387 | try:
388 | async with async_timeout.timeout(SETTING_UPDATE_TIMEOUT):
389 | return True, await self.hass.async_add_executor_job(
390 | func, *args, **kwargs
391 | )
392 |
393 | except asyncio.TimeoutError as err:
394 | _LOGGER.error("Timeout fetching data in function: %s", func.__name__)
395 | return False, (func.__name__, err)
396 | except NotLoggedIn as err:
397 | _LOGGER.error(
398 | "Session invalidated unexpectedly in function: %s", func.__name__
399 | )
400 | return False, (func.__name__, err)
401 | except CSGAPIError as err:
402 | _LOGGER.error(
403 | "Error fetching data in coordinator: API error, function %s, %s",
404 | func.__name__,
405 | err,
406 | )
407 | return False, (func.__name__, err)
408 | except Exception as err: # pylint: disable=broad-except
409 | _LOGGER.error("Unexpected exception: %s", err)
410 | _LOGGER.error(traceback.format_exc())
411 | return False, (func.__name__, err)
412 |
413 | async def _async_update_bal_arr(self, account: CSGElectricityAccount):
414 | """Update balance and arrears"""
415 | success, result = await self._async_fetch(
416 | self._client.get_balance_and_arrears, account
417 | )
418 | if success:
419 | balance, arrears = result
420 | _LOGGER.debug(
421 | "Updated balance and arrears for account %s: %s",
422 | account.account_number,
423 | result,
424 | )
425 | else:
426 | balance, arrears = STATE_UNAVAILABLE, STATE_UNAVAILABLE
427 | _LOGGER.error(
428 | "Error updating balance and arrears for account %s: %s",
429 | account.account_number,
430 | result,
431 | )
432 | self._gathered_data[account.account_number][SUFFIX_BAL] = balance
433 | self._gathered_data[account.account_number][SUFFIX_ARR] = arrears
434 |
435 | async def _async_update_yesterday_kwh(self, account: CSGElectricityAccount):
436 | """Update yesterday's kwh"""
437 | success, result = await self._async_fetch(
438 | self._client.get_yesterday_kwh,
439 | account,
440 | )
441 | if success and result is not None:
442 | yesterday_kwh = result
443 | _LOGGER.debug(
444 | "Updated yesterday's kwh for account %s: %s",
445 | account.account_number,
446 | result,
447 | )
448 | else:
449 | yesterday_kwh = STATE_UNAVAILABLE
450 | _LOGGER.error(
451 | "Error updating yesterday's kwh for account %s: %s",
452 | account.account_number,
453 | result,
454 | )
455 | self._gathered_data[account.account_number][
456 | SUFFIX_YESTERDAY_KWH
457 | ] = yesterday_kwh
458 |
459 | async def _async_update_this_year_stats(self, account: CSGElectricityAccount):
460 | """Update this year's data"""
461 | success, result = await self._async_fetch(
462 | self._client.get_year_month_stats, account, self._this_year
463 | )
464 | if success:
465 | (
466 | this_year_cost,
467 | this_year_kwh,
468 | this_year_by_month,
469 | ) = result
470 |
471 | _LOGGER.debug(
472 | "Updated this year's data for account %s: %s",
473 | account.account_number,
474 | result,
475 | )
476 | else:
477 | _LOGGER.error(
478 | "Error updating this year's data for account %s: %s",
479 | account.account_number,
480 | result,
481 | )
482 | this_year_cost, this_year_kwh, this_year_by_month = (
483 | STATE_UNAVAILABLE,
484 | STATE_UNAVAILABLE,
485 | STATE_UNAVAILABLE,
486 | )
487 | self._gathered_data[account.account_number][
488 | SUFFIX_THIS_YEAR_KWH
489 | ] = this_year_kwh
490 | self._gathered_data[account.account_number][
491 | SUFFIX_THIS_YEAR_COST
492 | ] = this_year_cost
493 | self._gathered_data[account.account_number][ATTR_KEY_THIS_YEAR_BY_MONTH] = {
494 | ATTR_KEY_THIS_YEAR_BY_MONTH: this_year_by_month
495 | }
496 |
497 | async def _async_update_last_year_stats(self, account: CSGElectricityAccount):
498 | """Update last year's data"""
499 | if not self._if_update_last_year:
500 | self._gathered_data[account.account_number][
501 | SUFFIX_LAST_YEAR_KWH
502 | ] = STATE_UPDATE_UNCHANGED
503 | self._gathered_data[account.account_number][
504 | SUFFIX_LAST_YEAR_COST
505 | ] = STATE_UPDATE_UNCHANGED
506 | self._gathered_data[account.account_number][ATTR_KEY_LAST_YEAR_BY_MONTH] = {
507 | ATTR_KEY_LAST_YEAR_BY_MONTH: STATE_UPDATE_UNCHANGED
508 | }
509 | _LOGGER.debug(
510 | "Last year's data for account %s: no need to update",
511 | account.account_number,
512 | )
513 | return
514 | success, result = await self._async_fetch(
515 | self._client.get_year_month_stats, account, self._last_year
516 | )
517 | if success:
518 | (
519 | last_year_cost,
520 | last_year_kwh,
521 | last_year_by_month,
522 | ) = result
523 |
524 | _LOGGER.debug(
525 | "Updated last year's data for account %s: %s",
526 | account.account_number,
527 | result,
528 | )
529 | else:
530 | _LOGGER.error(
531 | "Error updating last year's data for account %s: %s",
532 | account.account_number,
533 | result,
534 | )
535 | last_year_cost, last_year_kwh, last_year_by_month = (
536 | STATE_UNAVAILABLE,
537 | STATE_UNAVAILABLE,
538 | STATE_UNAVAILABLE,
539 | )
540 | self._gathered_data[account.account_number][
541 | SUFFIX_LAST_YEAR_KWH
542 | ] = last_year_kwh
543 | self._gathered_data[account.account_number][
544 | SUFFIX_LAST_YEAR_COST
545 | ] = last_year_cost
546 | self._gathered_data[account.account_number][ATTR_KEY_LAST_YEAR_BY_MONTH] = {
547 | ATTR_KEY_LAST_YEAR_BY_MONTH: last_year_by_month
548 | }
549 |
550 | @staticmethod
551 | def merge_by_day_data(
552 | by_day_from_cost: list | str,
553 | kwh_from_cost: float | str,
554 | by_day_from_usage: list | str,
555 | kwh_from_usage: float | str,
556 | ) -> (list | str, float | str):
557 | """Merge by_day_from_usage and by_day_from_cost data"""
558 | # merge by_day
559 | # determine which is the latest
560 | if (
561 | by_day_from_cost == STATE_UNAVAILABLE
562 | and by_day_from_usage == STATE_UNAVAILABLE
563 | ):
564 | by_day = STATE_UNAVAILABLE
565 | elif by_day_from_cost == STATE_UNAVAILABLE:
566 | by_day = by_day_from_usage
567 | elif by_day_from_usage == STATE_UNAVAILABLE:
568 | by_day = by_day_from_cost
569 | else:
570 | # both are available
571 | if len(by_day_from_cost) >= len(by_day_from_usage):
572 | # the result from daily cost is newer
573 | by_day = by_day_from_cost
574 | else:
575 | # the result from daily usage is newer
576 | # but since the result from daily cost contains cost data, need to merge them
577 | by_day = by_day_from_usage
578 | for idx, item in enumerate(by_day_from_cost):
579 | by_day[idx][WF_ATTR_CHARGE] = item[WF_ATTR_CHARGE]
580 |
581 | # determine which one to use as kwh
582 | if kwh_from_cost == STATE_UNAVAILABLE and kwh_from_usage == STATE_UNAVAILABLE:
583 | kwh = STATE_UNAVAILABLE
584 | elif kwh_from_cost == STATE_UNAVAILABLE:
585 | kwh = kwh_from_usage
586 | elif kwh_from_usage == STATE_UNAVAILABLE:
587 | kwh = kwh_from_cost
588 | else:
589 | # determine which kwh is the latest
590 | # get the larger one
591 | kwh = max(kwh_from_cost, kwh_from_usage)
592 | return by_day, kwh
593 |
594 | async def _async_update_this_month_stats_and_ladder(
595 | self, account: CSGElectricityAccount
596 | ):
597 | """Update this month's usage, cost and ladder"""
598 | # fetch usage and cost in parallel
599 | task_fetch_usage = asyncio.create_task(
600 | self._async_fetch(
601 | self._client.get_month_daily_usage_detail, account, self._this_month_ym
602 | )
603 | )
604 | task_fetch_cost = asyncio.create_task(
605 | self._async_fetch(
606 | self._client.get_month_daily_cost_detail, account, self._this_month_ym
607 | )
608 | )
609 |
610 | results = await asyncio.gather(task_fetch_usage, task_fetch_cost)
611 |
612 | (success_usage, result_usage), (success_cost, result_cost) = results
613 |
614 | if success_usage:
615 | this_month_kwh_from_usage, this_month_by_day_from_usage = result_usage
616 | else:
617 | this_month_kwh_from_usage = STATE_UNAVAILABLE
618 | this_month_by_day_from_usage = STATE_UNAVAILABLE
619 |
620 | if success_cost:
621 | (
622 | this_month_cost,
623 | this_month_kwh_from_cost,
624 | ladder,
625 | this_month_by_day_from_cost,
626 | ) = result_cost
627 | # special processing
628 | if this_month_cost is None:
629 | this_month_cost = STATE_UNAVAILABLE
630 | if this_month_kwh_from_cost is None:
631 | this_month_kwh_from_cost = STATE_UNAVAILABLE
632 | ladder_stage = (
633 | ladder[WF_ATTR_LADDER]
634 | if ladder[WF_ATTR_LADDER] is not None
635 | else STATE_UNAVAILABLE
636 | )
637 | ladder_remaining_kwh = (
638 | ladder[WF_ATTR_LADDER_REMAINING_KWH]
639 | if ladder[WF_ATTR_LADDER_REMAINING_KWH] is not None
640 | else STATE_UNAVAILABLE
641 | )
642 | ladder_tariff = (
643 | ladder[WF_ATTR_LADDER_TARIFF]
644 | if ladder[WF_ATTR_LADDER_TARIFF] is not None
645 | else STATE_UNAVAILABLE
646 | )
647 | ladder_start_date = (
648 | ladder[WF_ATTR_LADDER_START_DATE]
649 | if ladder[WF_ATTR_LADDER_START_DATE] is not None
650 | else STATE_UNAVAILABLE
651 | )
652 | else:
653 | (
654 | this_month_cost,
655 | this_month_kwh_from_cost,
656 | this_month_by_day_from_cost,
657 | ladder_stage,
658 | ladder_remaining_kwh,
659 | ladder_tariff,
660 | ladder_start_date,
661 | ) = (
662 | STATE_UNAVAILABLE,
663 | STATE_UNAVAILABLE,
664 | STATE_UNAVAILABLE,
665 | STATE_UNAVAILABLE,
666 | STATE_UNAVAILABLE,
667 | STATE_UNAVAILABLE,
668 | STATE_UNAVAILABLE,
669 | )
670 | this_month_by_day, this_month_kwh = self.merge_by_day_data(
671 | by_day_from_usage=this_month_by_day_from_usage,
672 | kwh_from_usage=this_month_kwh_from_usage,
673 | by_day_from_cost=this_month_by_day_from_cost,
674 | kwh_from_cost=this_month_kwh_from_cost,
675 | )
676 |
677 | if this_month_by_day == STATE_UNAVAILABLE:
678 | # need last month's data to update `latest_day` entity
679 | self._if_update_last_month = True
680 |
681 | self._gathered_data[account.account_number][
682 | SUFFIX_THIS_MONTH_KWH
683 | ] = this_month_kwh
684 | self._gathered_data[account.account_number][
685 | SUFFIX_THIS_MONTH_COST
686 | ] = this_month_cost
687 | self._gathered_data[account.account_number][ATTR_KEY_THIS_MONTH_BY_DAY] = {
688 | ATTR_KEY_THIS_MONTH_BY_DAY: this_month_by_day
689 | }
690 | self._gathered_data[account.account_number][
691 | SUFFIX_CURRENT_LADDER
692 | ] = ladder_stage
693 | self._gathered_data[account.account_number][
694 | SUFFIX_CURRENT_LADDER_REMAINING_KWH
695 | ] = ladder_remaining_kwh
696 | self._gathered_data[account.account_number][
697 | SUFFIX_CURRENT_LADDER_TARIFF
698 | ] = ladder_tariff
699 | self._gathered_data[account.account_number][
700 | ATTR_KEY_CURRENT_LADDER_START_DATE
701 | ] = {ATTR_KEY_CURRENT_LADDER_START_DATE: ladder_start_date}
702 |
703 | self._this_month_update_completed_flag.set()
704 |
705 | async def _async_update_last_month_stats(self, account: CSGElectricityAccount):
706 | """Update last month's usage and cost"""
707 | if not self._if_update_last_month:
708 | # original condition, don't need to update last month's data
709 |
710 | # wait for this month's data to be updated to see if last month's data is needed
711 | await self._this_month_update_completed_flag.wait()
712 |
713 | if not self._if_update_last_month:
714 | # don't need last month's data for latest day
715 | _LOGGER.debug(
716 | "Last month's data for account %s: no need to update",
717 | account.account_number,
718 | )
719 | self._gathered_data[account.account_number][
720 | SUFFIX_LAST_MONTH_KWH
721 | ] = STATE_UPDATE_UNCHANGED
722 | self._gathered_data[account.account_number][
723 | SUFFIX_LAST_MONTH_COST
724 | ] = STATE_UPDATE_UNCHANGED
725 | self._gathered_data[account.account_number][
726 | ATTR_KEY_LAST_MONTH_BY_DAY
727 | ] = {ATTR_KEY_LAST_MONTH_BY_DAY: STATE_UPDATE_UNCHANGED}
728 | return
729 |
730 | # continue to update last month's data
731 | # fetch usage and cost in parallel
732 | task_fetch_usage = asyncio.create_task(
733 | self._async_fetch(
734 | self._client.get_month_daily_usage_detail, account, self._last_month_ym
735 | )
736 | )
737 | task_fetch_cost = asyncio.create_task(
738 | self._async_fetch(
739 | self._client.get_month_daily_cost_detail, account, self._last_month_ym
740 | )
741 | )
742 |
743 | results = await asyncio.gather(task_fetch_usage, task_fetch_cost)
744 |
745 | (success_usage, result_usage), (success_cost, result_cost) = results
746 |
747 | if success_usage:
748 | last_month_kwh_from_usage, last_month_by_day_from_usage = result_usage
749 | else:
750 | last_month_kwh_from_usage = STATE_UNAVAILABLE
751 | last_month_by_day_from_usage = STATE_UNAVAILABLE
752 |
753 | if success_cost:
754 | (
755 | last_month_cost,
756 | last_month_kwh_from_cost,
757 | _, # ladder is discarded
758 | last_month_by_day_from_cost,
759 | ) = result_cost
760 |
761 | # for last month, it's safe to calculate total kwh from cost
762 | if not last_month_cost:
763 | last_month_cost = sum(
764 | d[WF_ATTR_CHARGE] for d in last_month_by_day_from_cost
765 | )
766 | if not last_month_kwh_from_cost:
767 | last_month_kwh_from_cost = sum(
768 | d[WF_ATTR_KWH] for d in last_month_by_day_from_cost
769 | )
770 | else:
771 | (
772 | last_month_cost,
773 | last_month_kwh_from_cost,
774 | last_month_by_day_from_cost,
775 | ) = (
776 | STATE_UNAVAILABLE,
777 | STATE_UNAVAILABLE,
778 | STATE_UNAVAILABLE,
779 | )
780 | last_month_by_day, last_month_kwh = self.merge_by_day_data(
781 | by_day_from_usage=last_month_by_day_from_usage,
782 | kwh_from_usage=last_month_kwh_from_usage,
783 | by_day_from_cost=last_month_by_day_from_cost,
784 | kwh_from_cost=last_month_kwh_from_cost,
785 | )
786 |
787 | self._gathered_data[account.account_number][
788 | SUFFIX_LAST_MONTH_KWH
789 | ] = last_month_kwh
790 | self._gathered_data[account.account_number][
791 | SUFFIX_LAST_MONTH_COST
792 | ] = last_month_cost
793 | self._gathered_data[account.account_number][ATTR_KEY_LAST_MONTH_BY_DAY] = {
794 | ATTR_KEY_LAST_MONTH_BY_DAY: last_month_by_day
795 | }
796 |
797 | def _update_latest_day(self, account: CSGElectricityAccount):
798 | this_month_by_day = self._gathered_data[account.account_number][
799 | ATTR_KEY_THIS_MONTH_BY_DAY
800 | ][ATTR_KEY_THIS_MONTH_BY_DAY]
801 | last_month_by_day = self._gathered_data[account.account_number][
802 | ATTR_KEY_LAST_MONTH_BY_DAY
803 | ][ATTR_KEY_LAST_MONTH_BY_DAY]
804 |
805 | if (
806 | this_month_by_day == STATE_UNAVAILABLE
807 | and last_month_by_day == STATE_UNAVAILABLE
808 | ):
809 | latest_day_kwh = STATE_UNAVAILABLE
810 | latest_day_cost = STATE_UNAVAILABLE
811 | latest_day_date = STATE_UNAVAILABLE
812 | else:
813 | if this_month_by_day != STATE_UNAVAILABLE and len(this_month_by_day) >= 1:
814 | # we have this month's data, use the latest day
815 | latest_day_kwh = this_month_by_day[-1][WF_ATTR_KWH]
816 | latest_day_cost = (
817 | this_month_by_day[-1].get(WF_ATTR_CHARGE) or STATE_UNAVAILABLE
818 | )
819 | latest_day_date = this_month_by_day[-1][WF_ATTR_DATE]
820 | else:
821 | # this month isn't available yet (typically during the first 3 days)
822 | # let's try last month
823 | if (
824 | last_month_by_day
825 | not in [
826 | STATE_UNAVAILABLE,
827 | STATE_UPDATE_UNCHANGED,
828 | ]
829 | and len(last_month_by_day) >= 1
830 | ):
831 | latest_day_kwh = last_month_by_day[-1][WF_ATTR_KWH]
832 | latest_day_cost = STATE_UNAVAILABLE
833 | latest_day_date = last_month_by_day[-1][WF_ATTR_DATE]
834 | else:
835 | _LOGGER.error(
836 | "Ele account %s, no latest day data available",
837 | account.account_number,
838 | )
839 | latest_day_kwh = STATE_UNAVAILABLE
840 | latest_day_cost = STATE_UNAVAILABLE
841 | latest_day_date = STATE_UNAVAILABLE
842 | self._gathered_data[account.account_number][
843 | SUFFIX_LATEST_DAY_KWH
844 | ] = latest_day_kwh
845 | self._gathered_data[account.account_number][
846 | SUFFIX_LATEST_DAY_COST
847 | ] = latest_day_cost
848 | self._gathered_data[account.account_number][ATTR_KEY_LATEST_DAY_DATE] = {
849 | ATTR_KEY_LATEST_DAY_DATE: latest_day_date
850 | }
851 |
852 | def _update_states(self):
853 | current_dt = datetime.datetime.now()
854 | this_year, this_month, this_day = (
855 | current_dt.year,
856 | current_dt.month,
857 | current_dt.day,
858 | )
859 | last_year, last_month = this_year - 1, this_month - 1
860 | if last_month == 0:
861 | last_month_ym = (last_year, 12)
862 | else:
863 | last_month_ym = (this_year, last_month)
864 | self._this_day = this_day
865 | self._this_year = this_year
866 | self._this_month_ym = (this_year, this_month)
867 | self._last_year = last_year
868 | self._last_month_ym = last_month_ym
869 |
870 | # for last month and last year data, they won't change over a long period of time
871 | # so we could use cache
872 | #
873 | # update policy for last month:
874 | # for the first days of a month,
875 | # update every `update_interval`.
876 | # for the rest of the time, do not update.
877 |
878 | # update policy for last year:
879 | # for the first days of Jan, update daily at first update
880 | # for the rest of the time, do not update
881 | #
882 | # when integration is reloaded, all updates will be triggered
883 | # so user could just reload the integration to refresh the data if needed
884 |
885 | if (
886 | self.hass.data[DOMAIN][self._config_entry_id].get(DATA_KEY_LAST_UPDATE_DAY)
887 | is None
888 | ):
889 | # first update
890 | update_last_month = True
891 | update_last_year = True
892 | _LOGGER.debug(
893 | "First update for account %s, getting all past data",
894 | self._config[CONF_USERNAME],
895 | )
896 | else:
897 | update_last_month = False
898 | update_last_year = False
899 |
900 | if this_day <= SETTING_LAST_MONTH_UPDATE_DAY_THRESHOLD:
901 | update_last_month = True
902 | today_first_update_triggered = (
903 | self.hass.data[DOMAIN][self._config_entry_id][DATA_KEY_LAST_UPDATE_DAY]
904 | == this_day
905 | )
906 | if this_month == 1 and this_day <= SETTING_LAST_YEAR_UPDATE_DAY_THRESHOLD:
907 | if not today_first_update_triggered:
908 | update_last_year = True
909 | self._if_update_last_month = update_last_month
910 | self._if_update_last_year = update_last_year
911 |
912 | async def _async_update_account_data(self, account: CSGElectricityAccount):
913 | start_time = time.time()
914 | # TODO use asyncio.TaskGroup() in 3.11
915 |
916 | # async with asyncio.TaskGroup() as task_group:
917 | # task_group.create_task(self._async_update_bal_arr(account))
918 | # task_group.create_task(self._async_update_yesterday_kwh(account))
919 | # task_group.create_task(self._async_update_this_year_stats(account))
920 | # task_group.create_task(self._async_update_last_year_stats(account))
921 | # task_group.create_task(
922 | # self._async_update_this_month_stats_and_ladder(account)
923 | # )
924 | # task_group.create_task(self._async_update_last_month_stats(account))
925 | await asyncio.gather(
926 | self._async_update_bal_arr(account),
927 | self._async_update_yesterday_kwh(account),
928 | self._async_update_this_year_stats(account),
929 | self._async_update_last_year_stats(account),
930 | self._async_update_this_month_stats_and_ladder(account),
931 | self._async_update_last_month_stats(account),
932 | return_exceptions=True,
933 | )
934 | try:
935 | self._update_latest_day(account)
936 | except Exception as exc: # pylint: disable=broad-except
937 | _LOGGER.error(
938 | "Ele account %s, update latest day data failed: %s",
939 | account.account_number,
940 | exc,
941 | )
942 |
943 | _LOGGER.debug(
944 | "Ele account %s, update took %s seconds",
945 | account.account_number,
946 | time.time() - start_time,
947 | )
948 |
949 | async def _async_update_data(self) -> dict[str, Any]:
950 | """Fetch data from API endpoint.
951 |
952 | This is the place to pre-process the data to lookup tables
953 | so entities can quickly look up their data.
954 | """
955 | self.update_interval = timedelta(
956 | seconds=self._config[CONF_SETTINGS][CONF_UPDATE_INTERVAL]
957 | )
958 | self._update_states()
959 | # _LOGGER.debug("Coordinator update interval: %d", self.update_interval.seconds)
960 | _LOGGER.debug("Coordinator update started")
961 | start_time = time.time()
962 |
963 | metering_point_data = {}
964 | config_entry_need_update = False
965 | await self._async_refresh_client()
966 | new_config = self._config.copy()
967 | for account_number, account_data in self._config[CONF_ELE_ACCOUNTS].items():
968 | self._gathered_data[account_number] = {}
969 | account = CSGElectricityAccount.load(account_data)
970 | # handling the addition of metering point number
971 | if not account.metering_point_number:
972 | if not metering_point_data:
973 | ok, data = await self._async_fetch(
974 | self._client.api_get_metering_point,
975 | account.area_code,
976 | account.ele_customer_id,
977 | )
978 | if ok:
979 | metering_point_data = data
980 | if metering_point_data:
981 | for mp in metering_point_data:
982 | if mp["eleCustNumber"] == account.account_number:
983 | config_entry_need_update = True
984 | account.metering_point_number = mp[
985 | JSON_KEY_METERING_POINT_NUMBER
986 | ]
987 | new_config[CONF_ELE_ACCOUNTS][
988 | account_number
989 | ] = account.dump()
990 | break
991 |
992 | await self._async_update_account_data(account)
993 | if config_entry_need_update:
994 | new_config[CONF_UPDATED_AT] = str(int(time.time() * 1000))
995 | self.hass.config_entries.async_update_entry(
996 | self.hass.config_entries.async_get_entry(self._config_entry_id),
997 | data=new_config,
998 | )
999 | _LOGGER.debug("Updated accounts with metering point number")
1000 | _LOGGER.debug("Coordinator update took %s seconds", time.time() - start_time)
1001 | self.hass.data[DOMAIN][self._config_entry_id][
1002 | DATA_KEY_LAST_UPDATE_DAY
1003 | ] = self._this_day
1004 | return self._gathered_data
1005 |
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/strings.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "step": {
4 | "user": {
5 | "title": "选择登录方式",
6 | "description": "选择用于登录南方电网账号的方式",
7 | "menu_options": {
8 | "sms_login": "短信验证码登录",
9 | "sms_pwd_login": "短信验证码和密码登录",
10 | "csg_qr_login": "南网APP扫码登录",
11 | "wx_qr_login": "微信扫码登录",
12 | "ali_qr_login": "支付宝扫码登录"
13 | }
14 | },
15 | "sms_login": {
16 | "title": "登录南方电网",
17 | "description": "使用手机号和短信验证码登录",
18 | "data": {
19 | "username": "手机号"
20 | }
21 | },
22 | "sms_pwd_login": {
23 | "title": "登录南方电网",
24 | "description": "使用手机号、密码和短信验证码登录",
25 | "data": {
26 | "username": "手机号",
27 | "password": "密码"
28 | }
29 | },
30 | "validate_sms_code": {
31 | "title": "输入短信验证码",
32 | "description": "验证码已发送到: {phone_no}",
33 | "data": {
34 | "code": "短信验证码"
35 | }
36 | },
37 | "qr_login": {
38 | "title": "扫码登录",
39 | "description": "{description}",
40 | "data": {
41 | "refresh_qr_code": "刷新二维码"
42 | }
43 | },
44 | "reauth_confirm": {
45 | "title": "重新登录账号",
46 | "description": "登录状态已过期,请重新登录"
47 | }
48 | },
49 | "error": {
50 | "cannot_connect": "网络异常",
51 | "invalid_auth": "登录信息无效",
52 | "qr_not_scanned": "扫描二维码并确认登录",
53 | "unknown": "未知错误: {error_detail}"
54 | },
55 | "abort": {
56 | "reauth_successful": "登录成功",
57 | "already_configured": "账号已添加,请勿重复添加"
58 | }
59 | },
60 | "options": {
61 | "step": {
62 | "init": {
63 | "title": "选择操作"
64 | },
65 | "add_account": {
66 | "title": "选择缴费号"
67 | },
68 | "settings": {
69 | "title": "参数设置",
70 | "data": {
71 | "update_interval": "更新间隔(秒)"
72 | }
73 | }
74 | },
75 | "abort": {
76 | "no_account": "账户未绑定缴费号",
77 | "all_added": "没有可添加到此集成的缴费号"
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "abort": {
4 | "already_configured": "\u8d26\u53f7\u5df2\u6dfb\u52a0\uff0c\u8bf7\u52ff\u91cd\u590d\u6dfb\u52a0",
5 | "reauth_successful": "\u767b\u5f55\u6210\u529f"
6 | },
7 | "error": {
8 | "cannot_connect": "\u7f51\u7edc\u5f02\u5e38",
9 | "invalid_auth": "\u767b\u5f55\u4fe1\u606f\u65e0\u6548",
10 | "qr_not_scanned": "\u626b\u63cf\u4e8c\u7ef4\u7801\u5e76\u786e\u8ba4\u767b\u5f55",
11 | "unknown": "\u672a\u77e5\u9519\u8bef: {error_detail}"
12 | },
13 | "step": {
14 | "qr_login": {
15 | "data": {
16 | "refresh_qr_code": "\u5237\u65b0\u4e8c\u7ef4\u7801"
17 | },
18 | "description": "{description}",
19 | "title": "\u626b\u7801\u767b\u5f55"
20 | },
21 | "reauth_confirm": {
22 | "description": "\u767b\u5f55\u72b6\u6001\u5df2\u8fc7\u671f\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55",
23 | "title": "\u91cd\u65b0\u767b\u5f55\u8d26\u53f7"
24 | },
25 | "sms_login": {
26 | "data": {
27 | "username": "\u624b\u673a\u53f7"
28 | },
29 | "description": "\u4f7f\u7528\u624b\u673a\u53f7\u548c\u77ed\u4fe1\u9a8c\u8bc1\u7801\u767b\u5f55",
30 | "title": "\u767b\u5f55\u5357\u65b9\u7535\u7f51"
31 | },
32 | "sms_pwd_login": {
33 | "data": {
34 | "password": "\u5bc6\u7801",
35 | "username": "\u624b\u673a\u53f7"
36 | },
37 | "description": "\u4f7f\u7528\u624b\u673a\u53f7\u3001\u5bc6\u7801\u548c\u77ed\u4fe1\u9a8c\u8bc1\u7801\u767b\u5f55",
38 | "title": "\u767b\u5f55\u5357\u65b9\u7535\u7f51"
39 | },
40 | "user": {
41 | "description": "\u9009\u62e9\u7528\u4e8e\u767b\u5f55\u5357\u65b9\u7535\u7f51\u8d26\u53f7\u7684\u65b9\u5f0f",
42 | "menu_options": {
43 | "ali_qr_login": "\u652f\u4ed8\u5b9d\u626b\u7801\u767b\u5f55",
44 | "csg_qr_login": "\u5357\u7f51APP\u626b\u7801\u767b\u5f55",
45 | "sms_login": "\u77ed\u4fe1\u9a8c\u8bc1\u7801\u767b\u5f55",
46 | "sms_pwd_login": "\u77ed\u4fe1\u9a8c\u8bc1\u7801\u548c\u5bc6\u7801\u767b\u5f55",
47 | "wx_qr_login": "\u5fae\u4fe1\u626b\u7801\u767b\u5f55"
48 | },
49 | "title": "\u9009\u62e9\u767b\u5f55\u65b9\u5f0f"
50 | },
51 | "validate_sms_code": {
52 | "data": {
53 | "code": "\u77ed\u4fe1\u9a8c\u8bc1\u7801"
54 | },
55 | "description": "\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u5230: {phone_no}",
56 | "title": "\u8f93\u5165\u77ed\u4fe1\u9a8c\u8bc1\u7801"
57 | }
58 | }
59 | },
60 | "options": {
61 | "abort": {
62 | "all_added": "\u6ca1\u6709\u53ef\u6dfb\u52a0\u5230\u6b64\u96c6\u6210\u7684\u7f34\u8d39\u53f7",
63 | "no_account": "\u8d26\u6237\u672a\u7ed1\u5b9a\u7f34\u8d39\u53f7"
64 | },
65 | "step": {
66 | "add_account": {
67 | "title": "\u9009\u62e9\u7f34\u8d39\u53f7"
68 | },
69 | "init": {
70 | "title": "\u9009\u62e9\u64cd\u4f5c"
71 | },
72 | "settings": {
73 | "data": {
74 | "update_interval": "\u66f4\u65b0\u95f4\u9694\uff08\u79d2\uff09"
75 | },
76 | "title": "\u53c2\u6570\u8bbe\u7f6e"
77 | }
78 | }
79 | }
80 | }
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/translations/zh-Hans.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "abort": {
4 | "already_configured": "\u8d26\u53f7\u5df2\u6dfb\u52a0\uff0c\u8bf7\u52ff\u91cd\u590d\u6dfb\u52a0",
5 | "reauth_successful": "\u767b\u5f55\u6210\u529f"
6 | },
7 | "error": {
8 | "cannot_connect": "\u7f51\u7edc\u5f02\u5e38",
9 | "invalid_auth": "\u767b\u5f55\u4fe1\u606f\u65e0\u6548",
10 | "qr_not_scanned": "\u626b\u63cf\u4e8c\u7ef4\u7801\u5e76\u786e\u8ba4\u767b\u5f55",
11 | "unknown": "\u672a\u77e5\u9519\u8bef: {error_detail}"
12 | },
13 | "step": {
14 | "qr_login": {
15 | "data": {
16 | "refresh_qr_code": "\u5237\u65b0\u4e8c\u7ef4\u7801"
17 | },
18 | "description": "{description}",
19 | "title": "\u626b\u7801\u767b\u5f55"
20 | },
21 | "reauth_confirm": {
22 | "description": "\u767b\u5f55\u72b6\u6001\u5df2\u8fc7\u671f\uff0c\u8bf7\u91cd\u65b0\u767b\u5f55",
23 | "title": "\u91cd\u65b0\u767b\u5f55\u8d26\u53f7"
24 | },
25 | "sms_login": {
26 | "data": {
27 | "username": "\u624b\u673a\u53f7"
28 | },
29 | "description": "\u4f7f\u7528\u624b\u673a\u53f7\u548c\u77ed\u4fe1\u9a8c\u8bc1\u7801\u767b\u5f55",
30 | "title": "\u767b\u5f55\u5357\u65b9\u7535\u7f51"
31 | },
32 | "sms_pwd_login": {
33 | "data": {
34 | "password": "\u5bc6\u7801",
35 | "username": "\u624b\u673a\u53f7"
36 | },
37 | "description": "\u4f7f\u7528\u624b\u673a\u53f7\u3001\u5bc6\u7801\u548c\u77ed\u4fe1\u9a8c\u8bc1\u7801\u767b\u5f55",
38 | "title": "\u767b\u5f55\u5357\u65b9\u7535\u7f51"
39 | },
40 | "user": {
41 | "description": "\u9009\u62e9\u7528\u4e8e\u767b\u5f55\u5357\u65b9\u7535\u7f51\u8d26\u53f7\u7684\u65b9\u5f0f",
42 | "menu_options": {
43 | "ali_qr_login": "\u652f\u4ed8\u5b9d\u626b\u7801\u767b\u5f55",
44 | "csg_qr_login": "\u5357\u7f51APP\u626b\u7801\u767b\u5f55",
45 | "sms_login": "\u77ed\u4fe1\u9a8c\u8bc1\u7801\u767b\u5f55",
46 | "sms_pwd_login": "\u77ed\u4fe1\u9a8c\u8bc1\u7801\u548c\u5bc6\u7801\u767b\u5f55",
47 | "wx_qr_login": "\u5fae\u4fe1\u626b\u7801\u767b\u5f55"
48 | },
49 | "title": "\u9009\u62e9\u767b\u5f55\u65b9\u5f0f"
50 | },
51 | "validate_sms_code": {
52 | "data": {
53 | "code": "\u77ed\u4fe1\u9a8c\u8bc1\u7801"
54 | },
55 | "description": "\u9a8c\u8bc1\u7801\u5df2\u53d1\u9001\u5230: {phone_no}",
56 | "title": "\u8f93\u5165\u77ed\u4fe1\u9a8c\u8bc1\u7801"
57 | }
58 | }
59 | },
60 | "options": {
61 | "abort": {
62 | "all_added": "\u6ca1\u6709\u53ef\u6dfb\u52a0\u5230\u6b64\u96c6\u6210\u7684\u7f34\u8d39\u53f7",
63 | "no_account": "\u8d26\u6237\u672a\u7ed1\u5b9a\u7f34\u8d39\u53f7"
64 | },
65 | "step": {
66 | "add_account": {
67 | "title": "\u9009\u62e9\u7f34\u8d39\u53f7"
68 | },
69 | "init": {
70 | "title": "\u9009\u62e9\u64cd\u4f5c"
71 | },
72 | "settings": {
73 | "data": {
74 | "update_interval": "\u66f4\u65b0\u95f4\u9694\uff08\u79d2\uff09"
75 | },
76 | "title": "\u53c2\u6570\u8bbe\u7f6e"
77 | }
78 | }
79 | }
80 | }
--------------------------------------------------------------------------------
/custom_components/china_southern_power_grid_stat/utils.py:
--------------------------------------------------------------------------------
1 | """helper functions"""
2 |
3 | import logging
4 |
5 | _LOGGER = logging.getLogger(__name__)
6 |
--------------------------------------------------------------------------------
/hacs.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "China Southern Power Grid Statistics",
3 | "render_readme": true,
4 | "country": "CN",
5 | "homeassistant": "2022.11"
6 | }
--------------------------------------------------------------------------------
/img/sensor_attr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CubicPill/china_southern_power_grid_stat/2794335f8538aec92d075cbb7326409d7994e003/img/sensor_attr.png
--------------------------------------------------------------------------------
/img/setup_add_account.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CubicPill/china_southern_power_grid_stat/2794335f8538aec92d075cbb7326409d7994e003/img/setup_add_account.png
--------------------------------------------------------------------------------
/img/setup_login.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CubicPill/china_southern_power_grid_stat/2794335f8538aec92d075cbb7326409d7994e003/img/setup_login.png
--------------------------------------------------------------------------------
/img/setup_params.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CubicPill/china_southern_power_grid_stat/2794335f8538aec92d075cbb7326409d7994e003/img/setup_params.png
--------------------------------------------------------------------------------
/img/setup_select_account.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CubicPill/china_southern_power_grid_stat/2794335f8538aec92d075cbb7326409d7994e003/img/setup_select_account.png
--------------------------------------------------------------------------------