├── jkopay.jpg
├── linepay.jpg
├── linebank.jpg
├── hacs.json
├── custom_components
└── foodpanda
│ ├── manifest.json
│ ├── binary_sensor.py
│ ├── button.py
│ ├── translations
│ ├── en.json
│ └── zh-Hant.json
│ ├── device_tracker.py
│ ├── const.py
│ ├── __init__.py
│ ├── sensor.py
│ ├── config_flow.py
│ └── data.py
├── docs
└── obtain_token.md
├── LICENSE
├── README_zh-Hant.md
└── README.md
/jkopay.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tsunglung/foodpanda/HEAD/jkopay.jpg
--------------------------------------------------------------------------------
/linepay.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tsunglung/foodpanda/HEAD/linepay.jpg
--------------------------------------------------------------------------------
/linebank.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tsunglung/foodpanda/HEAD/linebank.jpg
--------------------------------------------------------------------------------
/hacs.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "foodpanda",
3 | "render_readme": true,
4 | "domain": "foodpanda",
5 | "documentation": "https://github.com/tsunglung/foodpanda",
6 | "issue_tracker": "https://github.com/tsunglung/foodpanda/issues",
7 | "homeassistant": "2024.1.0"
8 | }
9 |
--------------------------------------------------------------------------------
/custom_components/foodpanda/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "domain": "foodpanda",
3 | "name": "foodpanda",
4 | "version": "0.1.0",
5 | "documentation": "https://github.com/tsunglung/foodpanda",
6 | "issue_tracker": "https://github.com/tsunglung/foodpanda/issues",
7 | "config_flow": true,
8 | "dependencies": [],
9 | "codeowners": [
10 | "@tsunglung"
11 | ],
12 | "requirements": [ "requests" ]
13 | }
14 |
--------------------------------------------------------------------------------
/docs/obtain_token.md:
--------------------------------------------------------------------------------
1 | ## Obtain Token
2 | 1. Login foodpanda with Facebook or Google account
3 | 2. Open the development tools (use Google chrome/Microsoft Edge) [Crtl+Shift+I / F12]
4 | 3. Open the Application tab on the console
5 | 4. Expand the "Cookies" dropdown under "Storage" section.
6 | 5. Under the Cookies, select the website "www.foodpanda.com" to see the cookie detail.
7 | 6. Search "token" in filter box, there are three tokens, "token", "device_token", "refresh_token".
8 |
9 | ## Obtain X-device
10 | 1. Open the development tools (use Google chrome/Microsoft Edge) [Crtl+Shift+I / F12]
11 | 2. Login foodpanda account and verified by email
12 | 3. Open the Network tab on the console
13 | 4. Search "login" in filter box, got to header tab, find the value of x-device
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 tsunglung
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README_zh-Hant.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Home assistant for foodpanda
4 |
5 |
6 | 使用本整合, 必須由你承擔任何風險.
7 |
8 | ## 安裝
9 |
10 | 你可以使用 [HACS](https://hacs.xyz/) 來安裝此整合元件. 步驟如下 custom repo: HACS > Integrations > 3 dots (upper top corner) > Custom repositories > URL: `tsunglung/foodpanda` > Category: Integration
11 |
12 | 或是手動複製 `foodpanda` 到你的設定資料夾 (像是 /config) 下的 `custom_components` 目錄.
13 |
14 | 然後重新啟動 HA.
15 |
16 | # 設定
17 |
18 | ** 使用 Home Assistant 整合**
19 |
20 | 1. 使用者介面, 設定 > 整合 > 新增整合 > foodpanda
21 | 1. 如果整合沒有出在清單裡,請重新整理網頁
22 | 2. 如果重新整理網頁後,整合還是沒有出在清單裡,請您清除瀏覽器的快取
23 | 2. 輸入 帳號和密碼 或是 輸入 tokens 如果是用 Fackbook 或 Google 登入 (香港/新加坡只能用 token 方法)
24 | 3. 如果是使用帳號/密碼登入,x-device 欄位是必要的. 你可以依照 [obtain_token](https://github.com/tsunglung/foodpanda/blob/master/docs/obtain_token.md#obtain-x-device) 取得 x-device 的值
25 | 4. 如果輸入都正確,就可以創建自動化,廣播外送進度到通訊軟體和 HomePod mini。
26 |
27 | # 注意
28 | 使用 Facebook 和 Google 方式登入,需要截取 Tokens.
29 |
30 | 打賞
31 |
32 | | LINE Pay | LINE Bank | JKao Pay |
33 | | :------------: | :------------: | :------------: |
34 | |
|
|
|
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Home assistant support for foodpanda
4 |
5 | [The readme in Traditional Chinese](https://github.com/tsunglung/foodpanda/blob/master/README_zh-Hant.md).
6 |
7 | ***User the integration by your own risk***
8 |
9 | ## Install
10 |
11 | You can install component with [HACS](https://hacs.xyz/) custom repo: HACS > Integrations > 3 dots (upper top corner) > Custom repositories > URL: `tsunglung/foodpanda` > Category: Integration
12 |
13 | Or manually copy `foodpanda` folder to `custom_components` folder in your config folder.
14 |
15 | Then restart HA.
16 |
17 | # Config
18 |
19 |
20 | ** Please use the config flow of Home Assistant**
21 |
22 |
23 | 1. With GUI. Configuration > Integration > Add Integration > foodpanda
24 | 1. If the integration didn't show up in the list please REFRESH the page
25 | 2. If the integration is still not in the list, you need to clear the browser cache.
26 | 2. Enter the account and password or enter tokens if use Facebook or Google login (For Hongkong, only tokens method).
27 | 3. The x-device field is required if using account and password to login. You can get x-device according to [obtain_token](https://github.com/tsunglung/foodpanda/blob/master/docs/obtain_token.md#obtain-x-device)
28 |
29 | # Notice
30 | You need login foodpanda in Web Browser once. Then login in foodpanda integration of HA.
31 |
32 | Buy me a Coffee
33 |
34 | | LINE Pay | LINE Bank | JKao Pay |
35 | | :------------: | :------------: | :------------: |
36 | |
|
|
|
37 |
--------------------------------------------------------------------------------
/custom_components/foodpanda/binary_sensor.py:
--------------------------------------------------------------------------------
1 | """Upload foodpanda New Order binary sensor instances."""
2 | import logging
3 |
4 | from homeassistant.components.binary_sensor import BinarySensorEntity
5 | from homeassistant.const import CONF_USERNAME
6 |
7 | from .const import (
8 | DEFAULT_NAME,
9 | DOMAIN,
10 | FOODPANDA_DATA,
11 | MANUFACTURER
12 | )
13 |
14 | _LOGGER = logging.getLogger(__name__)
15 |
16 |
17 | async def async_setup_entry(hass, config, async_add_devices):
18 | """Set up the binary sensors from a config entry."""
19 |
20 | if config.data.get(CONF_USERNAME, None):
21 | username = config.data[CONF_USERNAME]
22 | else:
23 | username = config.options[CONF_USERNAME]
24 |
25 | data = hass.data[DOMAIN][config.entry_id][FOODPANDA_DATA]
26 | device = foodpandaBinarySensor(hass, data, username)
27 |
28 | async_add_devices([device], update_before_add=True)
29 |
30 | class foodpandaBinarySensor(BinarySensorEntity):
31 | """Represent a binary sensor."""
32 |
33 | def __init__(self, hass, data, username):
34 | """Set initializing values."""
35 | super().__init__()
36 | self._name = "{} {}".format(DEFAULT_NAME, username)
37 | self._attributes = {}
38 | self._state = False
39 | self._username = username
40 | self._data = data
41 | self._https_result = None
42 | self.hass = hass
43 |
44 | @property
45 | def unique_id(self):
46 | """Return an unique ID."""
47 | uid = self._name.replace(" ", "_")
48 | return f"{uid}_new_order"
49 |
50 | @property
51 | def name(self):
52 | """Return the name of the sensor."""
53 | return f"{self._name} New Order"
54 |
55 | @property
56 | def state(self):
57 | """Return the state of the sensor."""
58 | return self._data.new_order
59 |
60 | @property
61 | def device_info(self):
62 | """Return Device Info."""
63 | return {
64 | 'identifiers': {(DOMAIN, self._username)},
65 | 'manufacturer': MANUFACTURER,
66 | 'name': self._name
67 | }
68 |
--------------------------------------------------------------------------------
/custom_components/foodpanda/button.py:
--------------------------------------------------------------------------------
1 | """Upload foodpanda Button instances."""
2 | import logging
3 |
4 | from homeassistant.components.button import ButtonEntity
5 | from homeassistant.const import CONF_USERNAME
6 |
7 | from .const import (
8 | DEFAULT_NAME,
9 | DOMAIN,
10 | FOODPANDA_DATA,
11 | FOODPANDA_COORDINATOR,
12 | MANUFACTURER
13 | )
14 |
15 | _LOGGER = logging.getLogger(__name__)
16 |
17 |
18 | async def async_setup_entry(hass, config, async_add_devices):
19 | """Set up the binary sensors from a config entry."""
20 |
21 | cookie = None
22 | if config.data.get(CONF_USERNAME, None):
23 | username = config.data[CONF_USERNAME]
24 | else:
25 | username = config.options[CONF_USERNAME]
26 |
27 | data = hass.data[DOMAIN][config.entry_id][FOODPANDA_DATA]
28 | coordinator = hass.data[DOMAIN][config.entry_id][FOODPANDA_COORDINATOR]
29 | device = foodpandaButton(username, data, coordinator)
30 |
31 | async_add_devices([device], update_before_add=True)
32 |
33 | class foodpandaButton(ButtonEntity):
34 | """Represent a binary sensor."""
35 |
36 | def __init__(self, username, data, coordinator):
37 | """Set initializing values."""
38 | super().__init__()
39 | self._name = "{} {}".format(DEFAULT_NAME, username)
40 | self._attributes = {}
41 | self._username = username
42 | self._data = data
43 | self._coordinator = coordinator
44 |
45 | @property
46 | def unique_id(self):
47 | """Return an unique ID."""
48 | uid = self._name.replace(" ", "_")
49 | return f"{uid}_order"
50 |
51 | @property
52 | def name(self):
53 | """Return the name of the button."""
54 | return f"{self._name} Order"
55 |
56 | @property
57 | def device_info(self):
58 | """Return Device Info."""
59 | return {
60 | 'identifiers': {(DOMAIN, self._username)},
61 | 'manufacturer': MANUFACTURER,
62 | 'name': self._name
63 | }
64 |
65 | async def async_press(self) -> None:
66 | """Press the button."""
67 | self._data.ordered = True
68 | await self._coordinator.async_request_refresh()
--------------------------------------------------------------------------------
/custom_components/foodpanda/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "abort": {
4 | "already_configured": "foodpanda is already configured"
5 | },
6 | "error": {
7 | "connection_error": "Connecting to foodpanda with error.",
8 | "cannot_connect": "Can't connect to foodpanda.",
9 | "unknown": "Unexpected error"
10 | },
11 | "flow_title": "foodpanda: {name}",
12 | "step": {
13 | "user": {
14 | "title": "Select Action",
15 | "data": {
16 | "action": "Action"
17 | }
18 | },
19 | "cloud": {
20 | "title": "Add foodpanda Cloud Account",
21 | "data": {
22 | "username": "Username",
23 | "password": "Password",
24 | "localcode": "Local Code",
25 | "x_device": "x_device"
26 | },
27 | "description": "Please enter connection settings of your foodpanda."
28 | },
29 | "token": {
30 | "title": "Add foodpanda by Tokens",
31 | "data": {
32 | "username": "Username",
33 | "token": "Token",
34 | "device_token": "Device Token",
35 | "refresh_token": "Refresh Token",
36 | "localcode": "Local Code",
37 | "x_device": "x_device"
38 | },
39 | "description": "You have to manually obtain foodpanda [tokens](https://github.com/tsunglung/foodpanda/blob/master/docs/obtain_token.md)."
40 | }
41 | }
42 | },
43 | "options": {
44 | "step": {
45 | "cloud": {
46 | "title": "foodpanda",
47 | "data": {
48 | "password": "Password",
49 | "localcode": "Local Code",
50 | "x_device": "x_device"
51 | }
52 | },
53 | "token": {
54 | "title": "foodpanda",
55 | "data": {
56 | "token": "Token",
57 | "device_token": "Device Token",
58 | "refresh_token": "Refresh Token",
59 | "localcode": "Local Code",
60 | "x_device": "x_device"
61 | }
62 | }
63 | }
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/custom_components/foodpanda/translations/zh-Hant.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {
3 | "abort": {
4 | "already_configured": "foodpanda \u5df2\u7d93\u8a2d\u5b9a"
5 | },
6 | "error": {
7 | "connection_error": "\u9023\u63a5 foodpanda \u6642\u767c\u751f\u932f\u8aa4",
8 | "cannot_connect": "\u7121\u6cd5\u9023\u63a5 foodpanda",
9 | "unknown": "\u672a\u77e5\u9810\u671f\u7684\u932f\u8aa4"
10 | },
11 | "flow_title": "foodpanda: {name}",
12 | "step": {
13 | "user": {
14 | "title": "\u9078\u64c7\u52d5\u4f5c",
15 | "data": {
16 | "action": "\u52d5\u4f5c",
17 | "cloud": "\u65b0\u589e foodpanda \u96f2\u5e33\u865f",
18 | "token": "\u900f\u904e Tokens \u65b0\u589e foodpanda"
19 | }
20 | },
21 | "cloud": {
22 | "title": "\u65b0\u589e foodpanda \u96f2\u5e33\u865f",
23 | "data": {
24 | "username": "\u5e33\u865f",
25 | "password": "\u5bc6\u78bc",
26 | "localcode": "\u570b\u5bb6/\u5730\u5340\u78bc",
27 | "x_device": "x_device"
28 | },
29 | "description": "\u8acb\u8f38\u5165\u4f60\u7684 foodpanda \u9023\u7dda\u8cc7\u8a0a."
30 | },
31 | "token": {
32 | "title": "\u900f\u904e Tokens \u65b0\u589e foodpanda",
33 | "data": {
34 | "username": "\u5e33\u865f",
35 | "token": "Token",
36 | "device_token": "Device Token",
37 | "refresh_token": "Refresh Token",
38 | "localcode": "\u570b\u5bb6/\u5730\u5340\u78bc",
39 | "x_device": "x_device"
40 | },
41 | "description": "\u4f60\u5fc5\u9808\u624b\u52d5\u53d6\u5f97 [tokens](https://github.com/tsunglung/foodpanda/blob/master/docs/obtain_token.md)."
42 | }
43 | }
44 | },
45 | "options": {
46 | "step": {
47 | "cloud": {
48 | "title": "foodpanda",
49 | "data": {
50 | "password": "\u5bc6\u78bc",
51 | "localcode": "\u570b\u5bb6/\u5730\u5340\u78bc",
52 | "x_device": "x_device"
53 | }
54 | },
55 | "token": {
56 | "title": "foodpanda",
57 | "data": {
58 | "token": "Token",
59 | "device_token": "Device Token",
60 | "refresh_token": "Refresh Token",
61 | "localcode": "\u570b\u5bb6/\u5730\u5340\u78bc",
62 | "x_device": "x_device"
63 | }
64 | }
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/custom_components/foodpanda/device_tracker.py:
--------------------------------------------------------------------------------
1 | """Support for the foodpanda."""
2 | import logging
3 | from typing import Callable
4 |
5 | from homeassistant.core import HomeAssistant
6 | from homeassistant.config_entries import ConfigEntry
7 | from homeassistant.components.device_tracker.const import SourceType
8 | from homeassistant.components.device_tracker.config_entry import TrackerEntity
9 | from homeassistant.const import CONF_USERNAME
10 |
11 | from .const import (
12 | DEFAULT_NAME,
13 | DOMAIN,
14 | FOODPANDA_DATA,
15 | FOODPANDA_ORDERS,
16 | MANUFACTURER
17 | )
18 |
19 | _LOGGER = logging.getLogger(__name__)
20 |
21 | async def async_setup_entry(
22 | hass: HomeAssistant, config: ConfigEntry, async_add_devices: Callable
23 | ) -> None:
24 | """Set up the foodpanda tracker from config."""
25 |
26 | if config.data.get(CONF_USERNAME, None):
27 | username = config.data[CONF_USERNAME]
28 | else:
29 | username = config.options[CONF_USERNAME]
30 | data = hass.data[DOMAIN][config.entry_id][FOODPANDA_DATA]
31 | device = foodpandaTrackerEntity(username, data)
32 |
33 | async_add_devices([device], update_before_add=True)
34 |
35 |
36 | class foodpandaTrackerEntity(TrackerEntity):
37 | """Implementation of a foodpanda tracker ."""
38 |
39 | def __init__(self, username, data):
40 | """Initialize the tracker."""
41 | self._state = None
42 | self._data = data
43 | self._attributes = {}
44 | self._attr_value = {}
45 | self._name = "{} {}".format(DEFAULT_NAME, username)
46 | self._username = username
47 | self._attr_latitude = None
48 | self._attr_longitude = None
49 |
50 | @property
51 | def unique_id(self):
52 | """Return an unique ID."""
53 | uid = self._name.replace(" ", "_")
54 | return f"{uid}_courier"
55 |
56 | @property
57 | def name(self):
58 | """Return the name of the tracker."""
59 | return f"{self._name} Courier"
60 |
61 | @property
62 | def device_info(self):
63 | """Return Device Info."""
64 | return {
65 | 'identifiers': {(DOMAIN, self._username)},
66 | 'manufacturer': MANUFACTURER,
67 | 'name': self
68 | ._name
69 | }
70 |
71 | @property
72 | def should_poll(self) -> bool:
73 | """No polling for entities that have location pushed."""
74 | return True
75 |
76 | @property
77 | def source_type(self):
78 | """Return the source type, eg gps or router, of the device."""
79 | return SourceType.GPS
80 |
81 | @property
82 | def latitude(self):
83 | """Return latitude value of the device."""
84 | return self._attr_latitude
85 |
86 | @property
87 | def longitude(self):
88 | """Return longitude value of the device."""
89 | return self._attr_longitude
90 |
91 | async def async_update(self):
92 | """Schedule a custom update via the common entity update service."""
93 | try:
94 | if self._username in self._data.orders:
95 | orders = self._data.orders[self._username].get(FOODPANDA_ORDERS, [])
96 | self._state = len(orders)
97 | index = 0
98 | if len(orders) >= 1:
99 | if isinstance(orders[0]['courier'], dict):
100 | self._attr_latitude = orders[0]['courier']['latitude']
101 | self._attr_longitude = orders[0]['courier']['longitude']
102 |
103 | except Exception as e:
104 | _LOGGER.error(f"paring orders occured exception {e}")
--------------------------------------------------------------------------------
/custom_components/foodpanda/const.py:
--------------------------------------------------------------------------------
1 | """Constants of the foodpanda component."""
2 | from datetime import timedelta
3 |
4 | DEFAULT_NAME = "foodpanda"
5 | DOMAIN = "foodpanda"
6 | PLATFORMS = [ "binary_sensor", "button", "device_tracker", "sensor" ]
7 | DATA_KEY = "data_foodpanda"
8 |
9 | ATTR_ETA = "eta"
10 | ATTR_RESTAURANT_NAME = "restaurant_name"
11 | ATTR_COURIER_NAME = "courier_name"
12 | ATTR_COURIER_PHONE = "courier_phone"
13 | ATTR_COURIER_DESCRIPTION = "courier_description"
14 | ATTR_TITLE_SUMMARY = "title_summary"
15 | ATTR_SUBTITLE_SUMMARY = "subtitle_summary"
16 | ATTR_LATITUDE = "latitude"
17 | ATTR_LONGITUDE = "longitude"
18 | ATTR_HTTPS_RESULT = "https_result"
19 | ATTR_LIST = [
20 | ATTR_ETA,
21 | ATTR_RESTAURANT_NAME,
22 | ATTR_COURIER_NAME,
23 | ATTR_COURIER_DESCRIPTION,
24 | ATTR_LATITUDE,
25 | ATTR_LONGITUDE,
26 | ATTR_SUBTITLE_SUMMARY,
27 | ATTR_TITLE_SUMMARY,
28 | ATTR_HTTPS_RESULT
29 | ]
30 |
31 | DEFAULT_SCAN_INTERVAL = timedelta(minutes=1)
32 |
33 | CONF_LOCALCODE = "localcode"
34 | CONF_TOKEN_TIMEOUT = "token_timeout"
35 | CONF_DEVICE_TOKEN = "device_token"
36 | CONF_REFRESH_TOKEN = "refresh_token"
37 | CONF_REFRESH_TOKEN_TIMEOUT = "refresh_token_timeout"
38 | CONF_CLIENTID = "clientid"
39 | CONF_SESSIONID = "sessionid"
40 | CONF_USERSOURCE = "usersouce"
41 | CONF_X_DEVICE = "x_device"
42 | ATTRIBUTION = "Powered by foodpanda Data"
43 | MANUFACTURER = "foodpanda"
44 | DEFAULT_LOCALCODE = "tw"
45 | FOODPANDA_COORDINATOR = "foodpanda_coordinator"
46 | FOODPANDA_DATA = "foodpanda_data"
47 | FOODPANDA_NAME = "foodpanda_name"
48 | FOODPANDA_ORDERS = "orders"
49 | UPDATE_LISTENER = "update_listener"
50 | DEFAULT_LOCALCODE = "tw"
51 |
52 | DEFAULT_X_DEVICE = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleW1ha2VyLXZvbG8tZGV2aWNlLWZwLXR3IiwidHlwIjoiSldUIn0.eyJpZCI6IjEzNTI4MjhjLWM0MTAtNDgyZi05ZjZkLTk2MGE4NDdiNzRkMCIsImNsaWVudF9pZCI6InZvbG8iLCJ1c2VyX2lkIjoidHdrMGNzcjMiLCJleHBpcmVzIjo0ODYwNDUyODYzLCJ0b2tlbl90eXBlIjoiYmVhcmVyIiwic2NvcGUiOiJERVZJQ0VfVE9LRU4ifQ.bNa-xs2e7LQcX9HkBNHQcwrc9m5JhV-34qXAaBvCT1yOV8fPT9udzXsRTXa1nt7Wx4l-oe58SKx-BGH5j75bJxgQRoZNl6oktaV_3M_GrjPLp4v1aqTQQCLVBhHbVfSn2Tm115M6WrfkG-paKgaBvwjqxKD2u3P7FniP5SnW8bchaph8t4hwlJOMbSC8vgIlyN0nCFUdjgWVEcil8MTkAndXE4OClx5_ebUo4mt5EiLeR8qiKTWgH0_aHmzu_kc9KX_lrHtQbyzDgsMjZiqSx8XdL4bOgNbKgUPqh3uaP6hvMtHdOepf3aCfrW7rMMQOLydXI5Kw2_dfasgoX-GDww"
53 |
54 | HA_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.72 Safari/537.36"
55 |
56 | LOGIN_URLS = {
57 | "tw": 'https://www.foodpanda.com.tw/login/new/api',
58 | "hk": 'https://www.foodpanda.hk/login/new/api',
59 | "sg": 'https://www.foodpanda.sg/login/new/api'
60 | }
61 |
62 | BASE_URLS = {
63 | "tw": 'https://tw.fd-api.com/api/v5',
64 | "hk": 'https://hk.fd-api.com/api/v5',
65 | "sg": 'https://sg.fd-api.com/api/v5'
66 | }
67 |
68 | REQUEST_TIMEOUT = 10 # seconds
69 |
70 | LOCALCODES = {
71 | # "en": "America/Los_Angeles",
72 | "tw": "Asia/Taipei",
73 | "hk": "Asia/Hong_Kong",
74 | "sg": "Asia/Singapore"
75 | }
76 |
77 | LANGUAGE_TRANSLATIONS = {
78 | "en": {
79 | "shop.order.status.message_awaiting_vendor_confirmation": "Awaiting vendor confirmation",
80 | "shop.order.status.message_order_accepted_by_vendor": "Order accepted by vendor",
81 | "shop.order.status.message_order_picked_by_rider": "Order picked by rider",
82 | "shop.order.status.message_order_rider_arrived": "Order rider arrvied"
83 | },
84 | "tw": {
85 | "shop.order.status.message_awaiting_vendor_confirmation": "\u8a02\u55ae\u6b63\u5728\u6e96\u5099\u4e2d\u3002",
86 | "shop.order.status.message_order_accepted_by_vendor": "\u9910\u5ef3\u5df2\u63a5\u53d7\u8a02\u55ae",
87 | "shop.order.status.message_order_picked_by_rider": "\u6b63\u524d\u5f80\u9818\u53d6\u8a02\u55ae\u3002",
88 | "shop.order.status.message_order_rider_arrived": "\u5373\u5c07\u62b5\u9054\u3002"
89 | }
90 | }
--------------------------------------------------------------------------------
/custom_components/foodpanda/__init__.py:
--------------------------------------------------------------------------------
1 | """The foodpanda integration."""
2 | import asyncio
3 | import logging
4 | from datetime import datetime
5 |
6 | from homeassistant.config_entries import ConfigEntry
7 | from homeassistant.core import HomeAssistant
8 | from homeassistant.exceptions import ConfigEntryNotReady
9 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
10 | from homeassistant.helpers.aiohttp_client import async_get_clientsession
11 | from homeassistant.helpers.storage import Store
12 | from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TOKEN
13 |
14 | from .const import (
15 | CONF_LOCALCODE,
16 | CONF_DEVICE_TOKEN,
17 | CONF_REFRESH_TOKEN,
18 | CONF_TOKEN_TIMEOUT,
19 | CONF_X_DEVICE,
20 | DEFAULT_SCAN_INTERVAL,
21 | DEFAULT_LOCALCODE,
22 | DEFAULT_X_DEVICE,
23 | DOMAIN,
24 | FOODPANDA_COORDINATOR,
25 | FOODPANDA_DATA,
26 | FOODPANDA_NAME,
27 | PLATFORMS,
28 | UPDATE_LISTENER
29 | )
30 | from .data import foodpandaData
31 |
32 | _LOGGER = logging.getLogger(__name__)
33 |
34 |
35 | async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
36 | """Set up a foodpanda entry."""
37 |
38 | username = _get_config_value(config_entry, CONF_USERNAME, "")
39 | login_info = {
40 | CONF_USERNAME: username,
41 | CONF_PASSWORD: _get_config_value(config_entry, CONF_PASSWORD, ""),
42 | CONF_TOKEN: _get_config_value(config_entry, CONF_TOKEN, ""),
43 | CONF_DEVICE_TOKEN: _get_config_value(config_entry, CONF_DEVICE_TOKEN, ""),
44 | CONF_REFRESH_TOKEN: _get_config_value(config_entry, CONF_REFRESH_TOKEN, ""),
45 | CONF_LOCALCODE: _get_config_value(config_entry, CONF_LOCALCODE, DEFAULT_LOCALCODE),
46 | CONF_X_DEVICE: _get_config_value(config_entry, CONF_X_DEVICE, DEFAULT_X_DEVICE)
47 | }
48 |
49 | # migrate data (also after first setup) to options
50 | if config_entry.data:
51 | hass.config_entries.async_update_entry(
52 | config_entry, data={}, options=config_entry.data)
53 |
54 | session = async_get_clientsession(hass)
55 |
56 | store = Store(hass, 1, f"{DOMAIN}/tokens.json")
57 | data = await store.async_load() or None
58 | if data:
59 | tokens = data.get(username, {})
60 | token_timeout = int(tokens.get(CONF_TOKEN_TIMEOUT, 0))
61 | now = datetime.now().timestamp()
62 |
63 | if (0 < int(token_timeout - now) < 3600):
64 | tokens[CONF_USERNAME] = username
65 | tokens[CONF_PASSWORD] = _get_config_value(config_entry, CONF_PASSWORD, "")
66 | tokens[CONF_LOCALCODE] = _get_config_value(config_entry, CONF_LOCALCODE, CONF_LOCALCODE)
67 | tokens[CONF_X_DEVICE] = _get_config_value(config_entry, CONF_X_DEVICE, DEFAULT_X_DEVICE)
68 | foodpanda_data = foodpandaData(hass, session, tokens)
69 | else:
70 | foodpanda_data = foodpandaData(hass, session, login_info)
71 |
72 | foodpanda_coordinator = DataUpdateCoordinator(
73 | hass,
74 | _LOGGER,
75 | name=f"foodpanda for {username}",
76 | update_method=foodpanda_data.async_update_data,
77 | update_interval=DEFAULT_SCAN_INTERVAL,
78 | )
79 |
80 | foodpanda_hass_data = hass.data.setdefault(DOMAIN, {})
81 | foodpanda_hass_data[config_entry.entry_id] = {
82 | FOODPANDA_DATA: foodpanda_data,
83 | FOODPANDA_COORDINATOR: foodpanda_coordinator,
84 | FOODPANDA_NAME: username,
85 | }
86 | foodpanda_data.expired = False
87 | foodpanda_data.ordered = True
88 |
89 | # Fetch initial data so we have data when entities subscribe
90 | await foodpanda_coordinator.async_refresh()
91 | if foodpanda_data.username is None:
92 | raise ConfigEntryNotReady()
93 |
94 | for platform in PLATFORMS:
95 | hass.async_create_task(
96 | hass.config_entries.async_forward_entry_setup(config_entry, platform)
97 | )
98 |
99 | update_listener = config_entry.add_update_listener(async_update_options)
100 | hass.data[DOMAIN][config_entry.entry_id][UPDATE_LISTENER] = update_listener
101 |
102 | return True
103 |
104 |
105 | async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry):
106 | """Update options."""
107 | await hass.config_entries.async_reload(config_entry.entry_id)
108 |
109 |
110 | async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry):
111 | """Unload a config entry."""
112 | unload_ok = all(
113 | await asyncio.gather(
114 | *[
115 | hass.config_entries.async_forward_entry_unload(config_entry, platform)
116 | for platform in PLATFORMS
117 | ]
118 | )
119 | )
120 | if unload_ok:
121 | update_listener = hass.data[DOMAIN][config_entry.entry_id][UPDATE_LISTENER]
122 | update_listener()
123 | hass.data[DOMAIN].pop(config_entry.entry_id)
124 | if not hass.data[DOMAIN]:
125 | hass.data.pop(DOMAIN)
126 | return unload_ok
127 |
128 |
129 | def _get_config_value(config_entry, key, default):
130 | if config_entry.options:
131 | return config_entry.options.get(key, default)
132 | return config_entry.data.get(key, default)
133 |
--------------------------------------------------------------------------------
/custom_components/foodpanda/sensor.py:
--------------------------------------------------------------------------------
1 | """Support for the foodpanda."""
2 | import logging
3 | from typing import Callable
4 | from http import HTTPStatus
5 |
6 | from homeassistant.core import HomeAssistant, callback
7 | from homeassistant.config_entries import ConfigEntry
8 | from homeassistant.components.sensor import SensorEntity
9 | from homeassistant.const import (
10 | ATTR_ATTRIBUTION,
11 | CONF_USERNAME
12 | )
13 |
14 | from .const import (
15 | ATTRIBUTION,
16 | ATTR_ETA,
17 | ATTR_RESTAURANT_NAME,
18 | ATTR_COURIER_NAME,
19 | ATTR_COURIER_PHONE,
20 | ATTR_COURIER_DESCRIPTION,
21 | ATTR_TITLE_SUMMARY,
22 | ATTR_SUBTITLE_SUMMARY,
23 | ATTR_LATITUDE,
24 | ATTR_LONGITUDE,
25 | ATTR_HTTPS_RESULT,
26 | ATTR_LIST,
27 | CONF_LOCALCODE,
28 | DEFAULT_NAME,
29 | DOMAIN,
30 | FOODPANDA_DATA,
31 | FOODPANDA_COORDINATOR,
32 | FOODPANDA_ORDERS,
33 | LANGUAGE_TRANSLATIONS,
34 | MANUFACTURER
35 | )
36 |
37 | _LOGGER = logging.getLogger(__name__)
38 |
39 |
40 | async def async_setup_entry(
41 | hass: HomeAssistant, config: ConfigEntry, async_add_devices: Callable
42 | ) -> None:
43 | """Set up the foodpanda Sensor from config."""
44 |
45 | if config.data.get(CONF_USERNAME, None):
46 | username = config.data[CONF_USERNAME]
47 | localcode = config.data[CONF_LOCALCODE]
48 | else:
49 | username = config.options[CONF_USERNAME]
50 | localcode = config.options[CONF_LOCALCODE]
51 |
52 | data = hass.data[DOMAIN][config.entry_id][FOODPANDA_DATA]
53 | data.expired = False
54 | data.ordered = False
55 | coordinator = hass.data[DOMAIN][config.entry_id][FOODPANDA_COORDINATOR]
56 | device = foodpandaSensor(username, localcode, data, coordinator)
57 |
58 | async_add_devices([device], update_before_add=True)
59 |
60 |
61 | class foodpandaSensor(SensorEntity):
62 | """Implementation of a foodpanda sensor."""
63 |
64 | def __init__(self, username, localcode, data, coordinator):
65 | """Initialize the sensor."""
66 | self._state = None
67 | self._data = data
68 | self._coordinator = coordinator
69 | self._attributes = {}
70 | self._attr_value = {}
71 | self._name = "{} {}".format(DEFAULT_NAME, username)
72 | self._username = username
73 |
74 | self._localcode = localcode
75 |
76 | @property
77 | def unique_id(self):
78 | """Return an unique ID."""
79 | uid = self._name.replace(" ", "_")
80 | return f"{uid}_orders"
81 |
82 | @property
83 | def name(self):
84 | """Return the name of the sensor."""
85 | return f"{self._name} Orders"
86 |
87 | @property
88 | def state(self):
89 | """Return the state of the sensor."""
90 | return self._state
91 |
92 | @property
93 | def icon(self):
94 | """Icon to use in the frontend, if any."""
95 | return "mdi:food-variant"
96 |
97 | @property
98 | def unit_of_measurement(self):
99 | """Return the unit of measurement."""
100 | return None
101 |
102 | @property
103 | def extra_state_attributes(self):
104 | """Return extra attributes."""
105 | self._attributes = {}
106 | self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION
107 | for k, _ in self._attr_value.items():
108 | self._attributes[k] = self._attr_value[k]
109 | return self._attributes
110 |
111 | @property
112 | def device_info(self):
113 | return {
114 | 'identifiers': {(DOMAIN, self._username)},
115 | 'manufacturer': MANUFACTURER,
116 | 'name': self._name
117 | }
118 |
119 | async def async_added_to_hass(self) -> None:
120 | """Set up a listener and load data."""
121 | self.async_on_remove(
122 | self._coordinator.async_add_listener(self._update_callback)
123 | )
124 | self._update_callback()
125 |
126 | @callback
127 | def _update_callback(self) -> None:
128 | """Load data from integration."""
129 | self.async_write_ha_state()
130 |
131 | async def async_update(self):
132 | """Schedule a custom update via the common entity update service."""
133 | await self._coordinator.async_request_refresh()
134 |
135 | self._attr_value = {}
136 | for i in ATTR_LIST:
137 | self._attr_value[i] = ''
138 | try:
139 | if self._username in self._data.orders:
140 | orders = self._data.orders[self._username].get(FOODPANDA_ORDERS, [])
141 | self._state = len(orders)
142 | index = 0
143 | if len(orders) >= 1:
144 | if self._localcode in LANGUAGE_TRANSLATIONS:
145 | translations = LANGUAGE_TRANSLATIONS[self._localcode]
146 | else:
147 | translations = LANGUAGE_TRANSLATIONS["en"]
148 |
149 | for order in orders:
150 | if index == 0:
151 | if "eta" in order:
152 | self._attr_value[ATTR_ETA] = order['eta']
153 | if "current_status" in order:
154 | msg = order['current_status']['message']
155 | self._attr_value[ATTR_TITLE_SUMMARY] = translations.get(msg, msg)
156 | if "delivery_time_range" in order:
157 | ss = "{} {} {}".format(
158 | order['delivery_time_range']['label'], order['delivery_time_range']['range'], order['delivery_time_range']['suffix'])
159 | self._attr_value[ATTR_SUBTITLE_SUMMARY] = ss
160 | if "vendor" in order:
161 | self._attr_value[ATTR_RESTAURANT_NAME] = order['vendor']['name']
162 | if 'courier' in order and isinstance(order['courier'], dict):
163 | self._attr_value[ATTR_COURIER_DESCRIPTION] = order['courier']['vehicle_type']
164 | name = "{} ({})".format(
165 | order['courier']['name'], order['courier']['id']
166 | )
167 | self._attr_value[ATTR_COURIER_NAME] = name
168 | self._attr_value[ATTR_COURIER_PHONE] = order['courier']['phone']
169 | self._attr_value[ATTR_LATITUDE] = str(order['courier']['latitude'])
170 | self._attr_value[ATTR_LONGITUDE] = str(order['courier']['longitude'])
171 | if index >= 1:
172 | if "eta" in order:
173 | self._attr_value[f"{ATTR_ETA}_{index + 1}"] = order['eta']
174 | if "current_status" in order:
175 | msg = order['current_status']['message']
176 | self._attr_value[f"{ATTR_TITLE_SUMMARY}_{index + 1}"] = translations.get(msg, msg)
177 | if "delivery_time_range" in order:
178 | ss = "{} {} {}".format(
179 | order['delivery_time_range']['label'], order['delivery_time_range']['range'], order['delivery_time_range']['suffix'])
180 | self._attr_value[f"{ATTR_SUBTITLE_SUMMARY}_{index + 1}"] = ss
181 | if "vendor" in order:
182 | self._attr_value[f"{ATTR_RESTAURANT_NAME}_{index + 1}"] = order['vendor']['name']
183 | if 'courier' in order and isinstance(order['courier'], dict):
184 | self._attr_value[f"{ATTR_COURIER_DESCRIPTION}_{index + 1}"] = order['courier']['vehicle_type']
185 | name = "{} ({})".format(
186 | order['courier']['name'], order['courier']['id']
187 | )
188 | self._attr_value[f"{ATTR_COURIER_NAME}_{index + 1}"] = name
189 | self._attr_value[f"{ATTR_COURIER_PHONE}_{index + 1}"] = order['courier']['phone']
190 | self._attr_value[f"{ATTR_LATITUDE}_{index + 1}"] = str(order['courier']['latitude'])
191 | self._attr_value[f"{ATTR_LONGITUDE}_{index + 1}"] = str(order['courier']['longitude'])
192 | index = index + 1
193 |
194 | except Exception as e:
195 | _LOGGER.error(f"paring orders occured exception {e}")
196 | self._state = 0
197 |
198 | self._attr_value[ATTR_HTTPS_RESULT] = self._data.orders[self._username].get(
199 | ATTR_HTTPS_RESULT, 'Unknown')
200 | if self._attr_value[ATTR_HTTPS_RESULT] == HTTPStatus.FORBIDDEN:
201 | self._state = None
202 |
203 | return
204 |
--------------------------------------------------------------------------------
/custom_components/foodpanda/config_flow.py:
--------------------------------------------------------------------------------
1 | """Config flow to configure foodpanda component."""
2 | import logging
3 | from typing import Optional
4 | import voluptuous as vol
5 |
6 | from homeassistant import core, exceptions
7 | from homeassistant.config_entries import (
8 | CONN_CLASS_CLOUD_POLL,
9 | ConfigFlow,
10 | OptionsFlow,
11 | ConfigEntry
12 | )
13 | from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TOKEN
14 | from homeassistant.core import callback
15 | from homeassistant.helpers.typing import ConfigType
16 | from homeassistant.helpers.aiohttp_client import async_get_clientsession
17 |
18 | from .const import (
19 | DOMAIN,
20 | CONF_DEVICE_TOKEN,
21 | CONF_REFRESH_TOKEN,
22 | CONF_LOCALCODE,
23 | CONF_X_DEVICE,
24 | DEFAULT_LOCALCODE,
25 | LOCALCODES
26 | )
27 | from .data import foodpandaData
28 |
29 | ACTIONS = {"cloud": "Add foodpanda Account", "token": "Add foodpanda by Tokens"}
30 |
31 | _LOGGER = logging.getLogger(__name__)
32 |
33 | async def validate_input(hass: core.HomeAssistant, data):
34 | """Validate that the user input allows us to connect to DataPoint.
35 |
36 | Data has the keys from DATA_SCHEMA with values provided by the user.
37 | """
38 |
39 | session = async_get_clientsession(hass)
40 | login_info = {
41 | CONF_USERNAME: data[CONF_USERNAME],
42 | CONF_PASSWORD: data.get(CONF_PASSWORD, ""),
43 | CONF_TOKEN: data.get(CONF_TOKEN, ""),
44 | CONF_DEVICE_TOKEN: data.get(CONF_DEVICE_TOKEN, ""),
45 | CONF_REFRESH_TOKEN: data.get(CONF_REFRESH_TOKEN, ""),
46 | CONF_LOCALCODE: data[CONF_LOCALCODE],
47 | CONF_X_DEVICE: data[CONF_X_DEVICE]
48 | }
49 |
50 | foodpanda_data = foodpandaData(hass, session, login_info)
51 | foodpanda_data.expired = False
52 | foodpanda_data.ordered = True
53 | await foodpanda_data.async_update_data()
54 | if foodpanda_data.username is None:
55 | raise CannotConnect()
56 |
57 | return {CONF_USERNAME: foodpanda_data.username}
58 |
59 | class foodpandaFlowHandler(ConfigFlow, domain=DOMAIN):
60 | """Handle a foodpanda config flow."""
61 |
62 | VERSION = 1
63 | CONNECTION_CLASS = CONN_CLASS_CLOUD_POLL
64 |
65 | def __init__(self):
66 | """Initialize flow."""
67 | self._username: Optional[str] = None
68 | self._password: Optional[str] = None
69 | self._localcode: Optional[str] = None
70 | self._x_device: Optional[str] = None
71 |
72 | @staticmethod
73 | @callback
74 | def async_get_options_flow(config_entry: ConfigEntry):
75 | """ get option flow """
76 | return OptionsFlowHandler(config_entry)
77 |
78 | async def async_step_user(self, user_input=None):
79 | if user_input is not None:
80 | if user_input["action"] == CONF_TOKEN:
81 | return await self.async_step_token()
82 | return await self.async_step_cloud()
83 |
84 | return self.async_show_form(
85 | step_id="user",
86 | data_schema=vol.Schema(
87 | {vol.Required("action", default="cloud"): vol.In(ACTIONS)}
88 | ),
89 | )
90 |
91 | async def async_step_cloud(
92 | self,
93 | user_input: Optional[ConfigType] = None
94 | ):
95 | """Handle a flow initialized by the user."""
96 | errors = {}
97 | if user_input is not None:
98 | await self.async_set_unique_id(
99 | f"{user_input[CONF_USERNAME]}"
100 | )
101 | self._abort_if_unique_id_configured()
102 |
103 | try:
104 | info = await validate_input(self.hass, user_input)
105 | except CannotConnect:
106 | errors["base"] = "cannot_connect"
107 | except Exception: # pylint: disable=broad-except
108 | _LOGGER.exception("Unexpected exception")
109 | errors["base"] = "unknown"
110 | else:
111 | user_input[CONF_USERNAME] = info[CONF_USERNAME]
112 | return self.async_create_entry(
113 | title=user_input[CONF_USERNAME], data=user_input
114 | )
115 |
116 | data_schema = vol.Schema(
117 | {
118 | vol.Required(CONF_USERNAME): str,
119 | vol.Required(CONF_PASSWORD): str,
120 | vol.Required(CONF_LOCALCODE, default=DEFAULT_LOCALCODE): vol.In(
121 | list(LOCALCODES.keys())
122 | ),
123 | vol.Required(CONF_X_DEVICE, default=''): str
124 | }
125 | )
126 |
127 | return self.async_show_form(
128 | step_id="cloud", data_schema=data_schema, errors=errors
129 | )
130 |
131 | async def async_step_token(self, user_input: dict = None, error=None):
132 |
133 | errors = {}
134 | if user_input is not None:
135 | await self.async_set_unique_id(
136 | f"{user_input[CONF_USERNAME]}"
137 | )
138 | self._abort_if_unique_id_configured()
139 |
140 | try:
141 | info = await validate_input(self.hass, user_input)
142 | except CannotConnect:
143 | errors["base"] = "cannot_connect"
144 | except Exception: # pylint: disable=broad-except
145 | _LOGGER.exception("Unexpected exception")
146 | errors["base"] = "unknown"
147 | else:
148 | user_input[CONF_USERNAME] = info[CONF_USERNAME]
149 | return self.async_create_entry(
150 | title=user_input[CONF_USERNAME], data=user_input
151 | )
152 |
153 | data_schema = vol.Schema(
154 | {
155 | vol.Required(CONF_USERNAME): str,
156 | vol.Required(CONF_TOKEN): str,
157 | vol.Required(CONF_DEVICE_TOKEN): str,
158 | vol.Required(CONF_REFRESH_TOKEN): str,
159 | vol.Required(CONF_LOCALCODE, default=DEFAULT_LOCALCODE): vol.In(
160 | list(LOCALCODES.keys())
161 | ),
162 | vol.Optional(CONF_X_DEVICE, default=''): str
163 | }
164 | )
165 |
166 | return self.async_show_form(
167 | step_id=CONF_TOKEN, data_schema=data_schema, errors=errors
168 | )
169 |
170 | @property
171 | def _name(self):
172 | # pylint: disable=no-member
173 | # https://github.com/PyCQA/pylint/issues/3167
174 | return self.context.get(CONF_USERNAME)
175 |
176 | @_name.setter
177 | def _name(self, value):
178 | # pylint: disable=no-member
179 | # https://github.com/PyCQA/pylint/issues/3167
180 | self.context[CONF_USERNAME] = value
181 | self.context["title_placeholders"] = {"name": self._username}
182 |
183 |
184 | class OptionsFlowHandler(OptionsFlow):
185 | # pylint: disable=too-few-public-methods
186 | """Handle options flow changes."""
187 | _username = None
188 | _password = None
189 | _token = None
190 | _device_token = None
191 | _refresh_token = None
192 | _localcode = None
193 | _x_device = None
194 |
195 | def __init__(self, config_entry):
196 | """Initialize options flow."""
197 | self.config_entry = config_entry
198 |
199 | async def async_step_init(self, user_input=None):
200 | if CONF_TOKEN in self.config_entry.options:
201 | return await self.async_step_token()
202 | return await self.async_step_cloud()
203 |
204 | async def async_step_cloud(self, user_input=None):
205 | """Manage the options."""
206 | errors = {}
207 | if user_input is not None:
208 | user_input[CONF_USERNAME] = self._username
209 | try:
210 | info = await validate_input(self.hass, user_input)
211 | except CannotConnect:
212 | errors["base"] = "cannot_connect"
213 | except Exception: # pylint: disable=broad-except
214 | _LOGGER.exception("Unexpected exception")
215 | errors["base"] = "unknown"
216 | else:
217 | user_input[CONF_USERNAME] = info[CONF_USERNAME]
218 | return self.async_create_entry(
219 | title=user_input[CONF_USERNAME], data=user_input
220 | )
221 |
222 | self._username = self.config_entry.options.get(CONF_USERNAME, '')
223 | self._password = self.config_entry.options.get(CONF_PASSWORD, '')
224 | self._localcode = self.config_entry.options.get(CONF_LOCALCODE, '')
225 | self._x_device = self.config_entry.options.get(CONF_X_DEVICE, '')
226 |
227 | return self.async_show_form(
228 | step_id="cloud",
229 | data_schema=vol.Schema(
230 | {
231 | vol.Required(CONF_PASSWORD, default=self._password): str,
232 | vol.Required(CONF_LOCALCODE, default=self._localcode): vol.In(
233 | list(LOCALCODES.keys())
234 | ),
235 | vol.Required(CONF_X_DEVICE, default=self._x_device): str
236 | }
237 | ),
238 | errors=errors
239 | )
240 |
241 | async def async_step_token(self, user_input=None):
242 | """Manage the options."""
243 | errors = {}
244 | if user_input is not None:
245 | user_input[CONF_USERNAME] = self._username
246 | try:
247 | info = await validate_input(self.hass, user_input)
248 | except CannotConnect:
249 | errors["base"] = "cannot_connect"
250 | except Exception: # pylint: disable=broad-except
251 | _LOGGER.exception("Unexpected exception")
252 | errors["base"] = "unknown"
253 | else:
254 | user_input[CONF_USERNAME] = info[CONF_USERNAME]
255 | return self.async_create_entry(
256 | title=user_input[CONF_USERNAME], data=user_input
257 | )
258 |
259 | self._username = self.config_entry.options.get(CONF_USERNAME, '')
260 | self._token = self.config_entry.options.get(CONF_TOKEN, '')
261 | self._device_token = self.config_entry.options.get(CONF_DEVICE_TOKEN, '')
262 | self._refresh_token = self.config_entry.options.get(CONF_REFRESH_TOKEN, '')
263 | self._localcode = self.config_entry.options.get(CONF_LOCALCODE, '')
264 | self._x_device = self.config_entry.options.get(CONF_X_DEVICE, '')
265 |
266 | return self.async_show_form(
267 | step_id=CONF_TOKEN,
268 | data_schema=vol.Schema(
269 | {
270 | vol.Required(CONF_TOKEN, default=self._token): str,
271 | vol.Required(CONF_DEVICE_TOKEN, default=self._device_token): str,
272 | vol.Required(CONF_REFRESH_TOKEN, default=self._refresh_token): str,
273 | vol.Required(CONF_LOCALCODE, default=self._localcode): vol.In(
274 | list(LOCALCODES.keys())
275 | ),
276 | vol.Optional(CONF_X_DEVICE, default=self._x_device): str
277 | }
278 | ),
279 | errors=errors
280 | )
281 |
282 | class CannotConnect(exceptions.HomeAssistantError):
283 | """Error to indicate we cannot connect."""
284 |
--------------------------------------------------------------------------------
/custom_components/foodpanda/data.py:
--------------------------------------------------------------------------------
1 | """Common foodpanda Data class used by both sensor and entity."""
2 |
3 | import logging
4 | from datetime import datetime, timezone
5 | import json
6 | from http import HTTPStatus
7 | import requests
8 | from aiohttp.hdrs import (
9 | ACCEPT,
10 | AUTHORIZATION,
11 | CONTENT_TYPE,
12 | METH_GET,
13 | METH_POST,
14 | USER_AGENT
15 | )
16 | from dateutil import tz as timezone
17 |
18 | from homeassistant.helpers.storage import Store
19 | from homeassistant.const import (
20 | CONF_USERNAME,
21 | CONF_PASSWORD,
22 | CONF_TOKEN,
23 | CONTENT_TYPE_JSON,
24 | EVENT_HOMEASSISTANT_STOP
25 | )
26 | from .const import (
27 | ATTR_HTTPS_RESULT,
28 | LOGIN_URLS,
29 | BASE_URLS,
30 | CONF_CLIENTID,
31 | CONF_LOCALCODE,
32 | CONF_DEVICE_TOKEN,
33 | CONF_TOKEN_TIMEOUT,
34 | CONF_REFRESH_TOKEN,
35 | CONF_REFRESH_TOKEN_TIMEOUT,
36 | CONF_SESSIONID,
37 | CONF_USERSOURCE,
38 | CONF_X_DEVICE,
39 | DEFAULT_X_DEVICE,
40 | DOMAIN,
41 | HA_USER_AGENT,
42 | REQUEST_TIMEOUT,
43 | FOODPANDA_ORDERS
44 | )
45 |
46 | _LOGGER = logging.getLogger(__name__)
47 |
48 |
49 | class foodpandaData():
50 | """Class for handling the data retrieval."""
51 |
52 | def __init__(self, hass, session, login_info):
53 | """Initialize the data object."""
54 | self._hass = hass
55 | self._session = session
56 | self._username = login_info[CONF_USERNAME]
57 | self._password = login_info[CONF_PASSWORD]
58 | self._localcode = login_info[CONF_LOCALCODE]
59 | self.orders = {}
60 | self.username = None
61 | self.expired = False
62 | self.ordered = False
63 | self.new_order = False
64 | self.uri = BASE_URLS.get(self._localcode, BASE_URLS["tw"])
65 | self.orders[login_info[CONF_USERNAME]] = {}
66 | self._last_check = datetime.now().timestamp()
67 | self._token = login_info[CONF_TOKEN]
68 | self._device_token = login_info[CONF_DEVICE_TOKEN]
69 | self._refresh_token = login_info[CONF_REFRESH_TOKEN]
70 | self._token_timeout = 0
71 | self._refresh_token_timeout = 0
72 | self._x_device = login_info[CONF_X_DEVICE] if len(login_info[CONF_X_DEVICE]) >= 1 else DEFAULT_X_DEVICE
73 | self._clientid = None
74 | self._sessionid = None
75 | self._usersource = "volo"
76 |
77 | def _format_cookies(self, cookies: str):
78 | """ format cookies """
79 | cookies_dict = {}
80 | for line in cookies.splitlines():
81 | cookie = line.replace("Set-Cookie: ", "")
82 | item = 0
83 | cookie_dict = {}
84 | for data in cookie.split(";"):
85 | if "=" in data:
86 | key = data.split("=")[0].lstrip()
87 | value = data.split("=")[1].lstrip()
88 | cookie_dict[key] = value
89 | if item == 0:
90 | cookies_dict[key] = cookie_dict
91 | item = item + 1
92 |
93 | return cookies_dict
94 |
95 | async def async_login(self):
96 | """ do login """
97 |
98 | headers = {
99 | USER_AGENT: HA_USER_AGENT,
100 | CONTENT_TYPE: CONTENT_TYPE_JSON,
101 | ACCEPT: 'application/json, text/plain, */*',
102 | 'x-device': self._x_device,
103 | 'x-otp-method': 'EMAIL',
104 | }
105 | payload={
106 | CONF_USERNAME: self._username,
107 | CONF_PASSWORD: self._password
108 | }
109 |
110 | try:
111 | response = await self._session.request(
112 | METH_POST,
113 | url=f"{LOGIN_URLS['tw']}/login",
114 | data=json.dumps(payload),
115 | headers=headers,
116 | timeout=REQUEST_TIMEOUT
117 | )
118 |
119 | except requests.exceptions.RequestException:
120 | _LOGGER.error("Failed fetching data for %s", self._username)
121 | return
122 |
123 | if response.status == HTTPStatus.OK:
124 | cookies = self._format_cookies(str(response.cookies))
125 | self._token = cookies.get(CONF_TOKEN, {}).get(CONF_TOKEN, "")
126 | self._token_timeout = cookies.get(CONF_TOKEN, {}).get("expires", "")
127 | self._device_token = cookies.get(CONF_DEVICE_TOKEN, {}).get(CONF_DEVICE_TOKEN, "")
128 | self._refresh_token = cookies.get(CONF_REFRESH_TOKEN, {}).get(CONF_REFRESH_TOKEN, "")
129 | self._refresh_token_timeout = cookies.get(CONF_REFRESH_TOKEN, {}).get("expires", "")
130 | self._clientid = cookies.get("dhhPerseusGuestId", {}).get("dhhPerseusGuestId", "")
131 | self._sessionid = cookies.get("dhhPerseusSessionId", {}).get("dhhPerseusSessionId", "")
132 | self._usersource = cookies.get("userSource", {}).get("userSource", self._usersource)
133 | try:
134 | self._token_timeout = int(datetime.strptime(
135 | self._token_timeout, "%a, %d %b %Y %H:%M:%S %Z").replace(tzinfo=timezone.gettz('Etc/GMT0')).timestamp())
136 | self._refresh_token_timeout = int(datetime.strptime(
137 | self._refresh_token_timeout, "%a, %d %b %Y %H:%M:%S %Z").replace(tzinfo=timezone.gettz('Etc/GMT0')).timestamp())
138 | except:
139 | self._token_timeout = 1893459660
140 | self._refresh_token_timeout = 1893459660
141 | else:
142 | info = ""
143 | self.orders[self._username][ATTR_HTTPS_RESULT] = response.status
144 | if response.status == HTTPStatus.FORBIDDEN:
145 | info = " Token is expired"
146 | _LOGGER.error(
147 | "Failed fetching data for %s (HTTP Status Code = %d).%s",
148 | self._username,
149 | response.status,
150 | info
151 | )
152 |
153 | async def async_refresh_token(self):
154 | """ do refresh token """
155 | payload = {
156 | "country": self._localcode,
157 | "platform": "b2c",
158 | CONF_DEVICE_TOKEN: self._device_token,
159 | CONF_REFRESH_TOKEN: self._refresh_token
160 | }
161 |
162 | headers = {
163 | USER_AGENT: HA_USER_AGENT,
164 | CONTENT_TYPE: f"{CONTENT_TYPE_JSON};charset=UTF-8",
165 | ACCEPT: 'application/json, text/plain, */*',
166 | 'x-device': self._x_device,
167 | 'x-otp-method': 'EMAIL',
168 | }
169 |
170 | uri = LOGIN_URLS.get(self._localcode, LOGIN_URLS["tw"])
171 | try:
172 | response = await self._session.request(
173 | METH_POST,
174 | url=f"{uri}/refresh-token",
175 | data=json.dumps(payload),
176 | headers=headers,
177 | timeout=REQUEST_TIMEOUT
178 | )
179 |
180 | except requests.exceptions.RequestException:
181 | _LOGGER.error("Failed fetching data for %s", self._username)
182 | return
183 |
184 | if response.status == HTTPStatus.OK:
185 | cookies = self._format_cookies(str(response.cookies))
186 | self._token = cookies.get(CONF_TOKEN, {}).get(CONF_TOKEN, self._token)
187 | self._device_token = cookies.get(CONF_DEVICE_TOKEN, {}).get(CONF_DEVICE_TOKEN, self._device_token)
188 | self._token_timeout = cookies.get(CONF_TOKEN, {}).get("expires", self._token_timeout)
189 | self._refresh_token_timeout = cookies.get(CONF_REFRESH_TOKEN, {}).get("expires", self._refresh_token_timeout)
190 | self._refresh_token = cookies.get(CONF_REFRESH_TOKEN, {}).get(CONF_REFRESH_TOKEN, self._refresh_token)
191 | self._clientid = cookies.get("dhhPerseusGuestId", {}).get("dhhPerseusGuestId", self._clientid)
192 | self._sessionid = cookies.get("dhhPerseusSessionId", {}).get("dhhPerseusSessionId", self._sessionid)
193 | self._usersource = cookies.get("userSource", {}).get("userSource", self._usersource)
194 |
195 | try:
196 | self._token_timeout = int(datetime.strptime(
197 | self._token_timeout, "%a, %d %b %Y %H:%M:%S %Z").replace(tzinfo=timezone.gettz('Etc/GMT0')).timestamp())
198 | self._refresh_token_timeout = int(datetime.strptime(
199 | self._refresh_token_timeout, "%a, %d %b %Y %H:%M:%S %Z").replace(
200 | tzinfo=timezone.gettz('Etc/GMT0')).timestamp())
201 | except:
202 | pass
203 | return True
204 | else:
205 | info = ""
206 | self.orders[self._username][ATTR_HTTPS_RESULT] = response.status
207 | if response.status == HTTPStatus.FORBIDDEN:
208 | info = " Token is expired"
209 | _LOGGER.error(
210 | "Failed fetching data for %s (HTTP Status Code = %d).%s",
211 | self._username,
212 | response.status,
213 | info
214 | )
215 | return False
216 |
217 | async def async_load_tokens(self) -> dict:
218 | """
219 | Update tokens in .storage
220 | """
221 | if self._token is not None:
222 | return {
223 | CONF_TOKEN: self._token,
224 | CONF_DEVICE_TOKEN: self._device_token,
225 | CONF_TOKEN_TIMEOUT: self._token_timeout,
226 | CONF_REFRESH_TOKEN: self._refresh_token,
227 | CONF_REFRESH_TOKEN_TIMEOUT: self._refresh_token_timeout,
228 | CONF_CLIENTID: self._clientid,
229 | CONF_SESSIONID: self._sessionid,
230 | CONF_USERSOURCE: self._usersource,
231 | CONF_X_DEVICE: self._x_device,
232 | CONF_LOCALCODE: self._localcode
233 | }
234 |
235 | default = {
236 | CONF_TOKEN: "",
237 | CONF_TOKEN_TIMEOUT: "1577836800",
238 | CONF_REFRESH_TOKEN: "",
239 | CONF_REFRESH_TOKEN_TIMEOUT: "1577836800"
240 | }
241 | store = Store(self._hass, 1, f"{DOMAIN}/tokens.json")
242 | data = await store.async_load() or None
243 | if not data:
244 | # force login
245 | return default
246 | tokens = data.get(self._username, default)
247 |
248 | # noinspection PyUnusedLocal
249 | async def stop(*args):
250 | # save devices data to .storage
251 | tokens = {
252 | CONF_TOKEN: self._token,
253 | CONF_DEVICE_TOKEN: self._device_token,
254 | CONF_TOKEN_TIMEOUT: self._token_timeout,
255 | CONF_REFRESH_TOKEN: self._refresh_token,
256 | CONF_REFRESH_TOKEN_TIMEOUT: self._refresh_token_timeout,
257 | CONF_X_DEVICE: self._x_device,
258 | CONF_CLIENTID: self._clientid,
259 | CONF_SESSIONID: self._sessionid,
260 | CONF_USERSOURCE: self._usersource,
261 | CONF_LOCALCODE: self._localcode
262 | }
263 | data[self._username] = tokens
264 |
265 | await store.async_save(data)
266 |
267 | self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop)
268 | return tokens
269 |
270 | async def async_store_tokens(self, tokens: dict):
271 | """
272 | Update tokens in .storage
273 | """
274 | store = Store(self._hass, 1, f"{DOMAIN}/tokens.json")
275 | data = await store.async_load() or {}
276 | data[self._username] = tokens
277 |
278 | await store.async_save(data)
279 |
280 | async def async_check_tokens(self):
281 | """ check the tokens if valid """
282 | tokens = await self.async_load_tokens()
283 |
284 | self._token = tokens.get(CONF_TOKEN, "")
285 | self._device_token = tokens.get(CONF_DEVICE_TOKEN, "")
286 | token_timeout = str(tokens.get(CONF_TOKEN_TIMEOUT, 0))
287 | self._refresh_token = tokens.get(CONF_REFRESH_TOKEN, "")
288 | refresh_token_timeout = str(tokens.get(CONF_REFRESH_TOKEN_TIMEOUT, 0))
289 | self._clientid = tokens.get(CONF_CLIENTID, "")
290 | self._sessionid = tokens.get(CONF_SESSIONID, "")
291 | self._usersource = tokens.get(CONF_USERSOURCE, "volo")
292 | self._x_device = tokens.get(CONF_X_DEVICE, "")
293 |
294 | if not isinstance(token_timeout, str):
295 | token_timeout = "1577836800"
296 | if not isinstance(refresh_token_timeout, str):
297 | refresh_token_timeout = "1577836800"
298 |
299 | now = datetime.now().timestamp()
300 | # configure foodpanda by tokens, there is not timeout of tokens.
301 | # do not login
302 | if len(self._password) < 1 or refresh_token_timeout == 0:
303 | refresh_token_timeout = int(now) + 31440000
304 | self._clientid = "1707192686179.449358429208716900.iohek328rn"
305 | self._sessionid = "1707192686179.978673916504541700.vnuoeucgts"
306 |
307 | self._token_timeout = token_timeout = int(token_timeout)
308 | self._refresh_token_timeout = refresh_token_timeout = int(refresh_token_timeout)
309 |
310 | updated_refresh_token = False
311 | timeout = refresh_token_timeout
312 |
313 | if ((int(timeout - now) < 86400) and
314 | len(self._password) >= 1):
315 | if self._localcode in ["hk", "sg"]:
316 | return False
317 | await self.async_login()
318 | if len(self._token) < 1:
319 | return False
320 | updated_refresh_token = True
321 | await self.async_store_tokens({
322 | CONF_TOKEN: self._token,
323 | CONF_DEVICE_TOKEN: self._device_token,
324 | CONF_TOKEN_TIMEOUT: self._token_timeout,
325 | CONF_REFRESH_TOKEN: self._refresh_token,
326 | CONF_REFRESH_TOKEN_TIMEOUT: self._refresh_token_timeout,
327 | CONF_CLIENTID: self._clientid,
328 | CONF_SESSIONID: self._sessionid,
329 | CONF_USERSOURCE: self._usersource,
330 | CONF_X_DEVICE: self._x_device,
331 | CONF_LOCALCODE: self._localcode
332 | })
333 |
334 | timeout = token_timeout
335 |
336 | if ((int(timeout - now) < 600) and
337 | not updated_refresh_token):
338 | ret = await self.async_refresh_token()
339 | if not ret:
340 | return False
341 | await self.async_store_tokens({
342 | CONF_TOKEN: self._token,
343 | CONF_DEVICE_TOKEN: self._device_token,
344 | CONF_TOKEN_TIMEOUT: self._token_timeout,
345 | CONF_REFRESH_TOKEN: self._refresh_token,
346 | CONF_REFRESH_TOKEN_TIMEOUT: self._refresh_token_timeout,
347 | CONF_CLIENTID: self._clientid,
348 | CONF_SESSIONID: self._sessionid,
349 | CONF_USERSOURCE: self._usersource,
350 | CONF_X_DEVICE: self._x_device,
351 | CONF_LOCALCODE: self._localcode
352 | })
353 |
354 | self.username = self._username
355 | return True
356 |
357 | async def async_check_order_history(self):
358 | """ check the order history """
359 | """ https://tw.fd-api.com/api/v5/orders/order_history?include=order_products,order_details """
360 | payload = {}
361 | headers = {
362 | USER_AGENT: HA_USER_AGENT,
363 | AUTHORIZATION: f"Bearer {self._token}",
364 | CONTENT_TYPE: CONTENT_TYPE_JSON,
365 | ACCEPT: 'application/json, text/plain, */*',
366 | 'Perseus-Client-Id': self._clientid,
367 | 'Perseus-Session-Id': self._sessionid,
368 | 'X-Fp-Api-Key': self._usersource
369 | }
370 | try:
371 | response = await self._session.request(
372 | METH_GET,
373 | url=f"{self.uri}/orders/order_history?include=order_products",
374 | data=json.dumps(payload),
375 | headers=headers,
376 | timeout=REQUEST_TIMEOUT
377 | )
378 |
379 | except requests.exceptions.RequestException:
380 | _LOGGER.error("Failed fetching data for %s", self._username)
381 | return
382 |
383 | orders = []
384 | if response.status == HTTPStatus.OK:
385 | try:
386 | res = await response.json()
387 | except:
388 | res = {"data": response.text}
389 |
390 | data = res.get('data', {})
391 | self.orders[self._username] = {}
392 | if len(data) >= 1:
393 | for item in data['items']:
394 | if (item['current_status']['code'] != 16 and item['current_status']['code'] != 13):
395 | orders.append(item)
396 | self.orders[self._username][ATTR_HTTPS_RESULT] = response.status
397 | else:
398 | info = ""
399 | self.orders[self._username][ATTR_HTTPS_RESULT] = response.status
400 | if response.status == HTTPStatus.FORBIDDEN:
401 | info = " Token is expired"
402 | _LOGGER.error(
403 | "Failed fetching data for %s (HTTP Status Code = %d).%s",
404 | self._username,
405 | response.status,
406 | info
407 | )
408 |
409 | return orders
410 |
411 |
412 | async def async_order_tracking(self, order_code):
413 | """ check the order tracking """
414 | payload = {}
415 | headers = {
416 | USER_AGENT: HA_USER_AGENT,
417 | AUTHORIZATION: f"Bearer {self._token}",
418 | CONTENT_TYPE: CONTENT_TYPE_JSON,
419 | ACCEPT: 'application/json, text/plain, */*',
420 | 'X-Fp-Api-Key': self._usersource
421 | }
422 | params = {
423 | "order_status_variation": "Control"
424 | }
425 |
426 | try:
427 | response = await self._session.request(
428 | METH_GET,
429 | url=f"{self.uri}/tracking/orders/{order_code}",
430 | data=json.dumps(payload),
431 | params=params,
432 | headers=headers,
433 | timeout=REQUEST_TIMEOUT
434 | )
435 |
436 | except requests.exceptions.RequestException:
437 | _LOGGER.error("Failed fetching data for %s", self._username)
438 | return
439 | data = {}
440 |
441 | if response.status == HTTPStatus.OK:
442 | try:
443 | res = await response.json()
444 | except:
445 | res = {"data": response.text}
446 | data = res.get('data', {})
447 | if len(data) >= 1:
448 | self.orders[self._username][FOODPANDA_ORDERS].append(data)
449 | self.orders[self._username][ATTR_HTTPS_RESULT] = HTTPStatus.OK
450 | self.expired = False
451 | elif response.status == HTTPStatus.NOT_FOUND:
452 | self.orders[self._username][ATTR_HTTPS_RESULT] = HTTPStatus.NOT_FOUND
453 | self.expired = True
454 | else:
455 | info = ""
456 | self.orders[self._username][ATTR_HTTPS_RESULT] = response.status
457 | if response.status == HTTPStatus.FORBIDDEN:
458 | info = " Token is expired"
459 | _LOGGER.error(
460 | "Failed fetching data for %s (HTTP Status Code = %d).%s",
461 | self._username,
462 | response.status,
463 | info
464 | )
465 | self.expired = True
466 | elif self.expired:
467 | self.orders[self._username][ATTR_HTTPS_RESULT] = 'sessions_expired'
468 | _LOGGER.warning(
469 | "Failed fetching data for %s (Sessions expired)",
470 | self._username,
471 | )
472 | return data
473 |
474 | async def async_update_data(self):
475 | """Get the latest data for foodpanda from REST service."""
476 |
477 | force_update = False
478 | now = datetime.now().timestamp()
479 |
480 | if (int(now - self._last_check) > 300):
481 | force_update = True
482 | self._last_check = now
483 |
484 | if not self.expired and (self.ordered or force_update):
485 |
486 | ret = await self.async_check_tokens()
487 | if not ret:
488 | return self
489 |
490 | data = await self.async_check_order_history()
491 | if len(data) < 1:
492 | self.new_order = False
493 | self.ordered = False
494 | return self
495 |
496 | if len(data) >= 1:
497 | self.new_order = True
498 | self.ordered = True
499 | self.orders[self._username][FOODPANDA_ORDERS] = []
500 | for order in data:
501 | await self.async_order_tracking(order['order_code'])
502 | return self
503 |
--------------------------------------------------------------------------------