├── 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 | Buy Me A Coffee 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 | | Line Pay | Line Bank | JKo Pay | 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Buy Me A Coffee 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 | | Line Pay | Line Bank | JKo Pay | 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 | --------------------------------------------------------------------------------