├── README.md └── custom_components └── airnut ├── __init__.py ├── const.py ├── manifest.json └── sensor.py /README.md: -------------------------------------------------------------------------------- 1 | # 空气果 Fun Home Assistant 插件 2 | 3 | ## 接入方式 4 | 5 | 1. 在路由器自定义域名(DNS劫持,或其他名字)中设置***apn2.airnut.com***指向自己的Home Assistant内网地址,比如我的是***10.0.0.10***,具体方法建议自行搜索。 6 | 2. 用[Easylink app](https://www.mxchip.com/easylink/)连接好WiFi后(空气果亮绿灯即连接成功),双击退出WiFi连接模式。 7 | 3. 通过hacs安装,或者复制文件到custom_components 8 | 4. 进行如下配置 9 | ### 10 | 1. 夜间是否更新 is_night_update: False 这功能已经实现,可正常使用 11 | 2. 默认情况如下,is_night_update=true 24小时更新,is_night_update=false,夜间停止更新,其余时间照常更新,想一直关闭更新把夜间开始时间改成 00:00:00 12 | 3. 检测时间 由 SCAN_INTERVAL = datetime.timedelta(seconds=120) 控制,需要间隔多久自己修改,默认2分钟 13 | 4. 想一直亮屏可以修改为1分钟或者更低(触发一次更新pm2.5,会持续亮屏1-2分钟) 14 | 15 | ``` 16 | # 这个是必须有的 17 | airnut: 18 | #开启定时更新数据 true=24小时更新,false=夜间停止更新,其余时间照常更新,一直关闭更新把夜间开始时间改成 00:00:00 19 | is_night_update: False 20 | #夜间开始时间 21 | night_start_hour: 0001-01-01 23:00:00 22 | #这里有两个选择,上面的是夜间停止更新,下面的是关闭所有时段自动检测功能 23 | night_start_hour: 0001-01-01 0:00:00 24 | #夜间结束时间 25 | night_end_hour: 0001-01-01 06:00:00 26 | #天气城市代码 27 | weathe_code: 101280800 28 | 29 | # ip为空气果内网的ip地址,空气果1s共四项数据,分别写四个类型的传感器 30 | sensor: 31 | - platform: airnut 32 | ip: "10.0.0.105" 33 | type: temperature 34 | - platform: airnut 35 | ip: "10.0.0.105" 36 | type: humidity 37 | - platform: airnut 38 | ip: "10.0.0.105" 39 | type: pm25 40 | - platform: airnut 41 | ip: "10.0.0.105" 42 | type: battery 43 | - platform: airnut 44 | ip: "10.0.0.105" 45 | type: weathe 46 | 47 | # 如果有第二个空气果,可以在下面继续,以此类推 48 | - platform: airnut 49 | ip: "10.0.0.xxx" 50 | type: temperature 51 | - platform: airnut 52 | ip: "10.0.0.xxx" 53 | type: humidity 54 | - platform: airnut 55 | ip: "10.0.0.xxx" 56 | type: pm25 57 | - platform: airnut 58 | ip: "10.0.0.xxx" 59 | type: battery 60 | - platform: airnut 61 | ip: "10.0.0.xxx" 62 | type: weathe 63 | 64 | 65 | 66 | # 定义图标和名称 67 | # 在customize.yaml文件插入下面几项 68 | sensor.airnut_fun_pm25: 69 | icon: mdi:blur 70 | friendly_name: 空气质量 71 | sensor.airnut_fun_battery: 72 | icon: mdi:battery 73 | friendly_name: 电量 74 | sensor.airnut_fun_temperature: 75 | icon: mdi:thermometer 76 | friendly_name: 温度 77 | sensor.airnut_fun_humidity: 78 | icon: mdi:water-percent 79 | friendly_name: 湿度 80 | sensor.airnut_fun_weathe: 81 | icon: mdi:weather-windy 82 | friendly_name: 天气 83 | 84 | ``` 85 | ## 里面的城市天气代码需要改成你所在的城市代码 86 | ## 代码请到这里寻找 87 | https://help.bj.cn/Weathera/20200304/320AD84ECBB0C14FBCF3518941E56179.html 88 | http://api.help.bj.cn/api/CityCode.XLS 89 | https://cdn.heweather.com/china-city-list.txt 城市代码表 90 | ## 天气每隔10分钟更新一次,可谓聊胜于无 91 | 92 | # 如果遇到时间不准确,或者是utc时间,请看下面 93 | ## 找到项目里面的_init_.py文件,找到下面 94 | ## def get_time_unix(): 95 | ## return int((datetime.datetime.now() + datetime.timedelta(hours=8)).timestamp()) 96 | ## 改成 97 | ## return int((datetime.datetime.now() + datetime.timedelta(hours=8)).timestamp()) 98 | ## 或者 99 | ## return int((datetime.datetime.utcnow() + datetime.timedelta(hours=8)).timestamp()) 100 | ## 请自行测试那一条适用,导致这个原因是docker环境或者主机环境时区问题影响,每个设备不能同时照顾 101 | 102 | # 其他 103 | ### 我也是修改的,没有利益关系,如有其它冲突,请告诉 104 | 105 | ### 最后谢谢之前写airnut 1s的大佬,[原贴地址](https://github.com/billhu1996/Airnut/) 106 | 107 | 108 | -------------------------------------------------------------------------------- /custom_components/airnut/__init__.py: -------------------------------------------------------------------------------- 1 | """Airnut Platform""" 2 | 3 | import logging 4 | import datetime 5 | import json 6 | import select 7 | import voluptuous as vol 8 | import threading 9 | import time 10 | import requests 11 | from socket import socket, AF_INET, SOCK_STREAM, SOL_SOCKET, SO_REUSEADDR 12 | 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.helpers.typing import HomeAssistantType 15 | 16 | from homeassistant import config_entries 17 | import homeassistant.helpers.config_validation as cv 18 | from homeassistant.const import ( 19 | CONF_SCAN_INTERVAL, 20 | ) 21 | 22 | from .const import ( 23 | DOMAIN, 24 | ATTR_TEMPERATURE, 25 | ATTR_HUMIDITY, 26 | ATTR_PM25, 27 | ATTR_BATTERY, 28 | ATTR_VOLUME, 29 | ATTR_WEATHE, 30 | ATTR_TIME, 31 | ) 32 | 33 | CONF_NIGHT_START_HOUR = "night_start_hour" 34 | CONF_NIGHT_END_HOUR = "night_end_hour" 35 | CONF_IS_NIGHT_UPDATE = "is_night_update" 36 | HOST_IP = "0.0.0.0" 37 | CONF_WEATHE_CODE = "weathe_code" 38 | SCAN_INTERVAL = datetime.timedelta(seconds=120) 39 | ZERO_TIME = datetime.datetime.fromtimestamp(0) 40 | weathestate= 0 41 | weathe_status = "" 42 | weathe_code = 101010100 43 | 44 | 45 | CONFIG_SCHEMA = vol.Schema( 46 | { 47 | DOMAIN: vol.Schema( 48 | { 49 | vol.Optional(CONF_NIGHT_START_HOUR, default=ZERO_TIME): cv.datetime, 50 | vol.Optional(CONF_NIGHT_END_HOUR, default=ZERO_TIME): cv.datetime, 51 | vol.Optional(CONF_IS_NIGHT_UPDATE, default=True): cv.boolean, 52 | vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period, 53 | vol.Optional(CONF_WEATHE_CODE, default="101010100"): cv.string, 54 | } 55 | ) 56 | }, 57 | extra=vol.ALLOW_EXTRA, 58 | ) 59 | 60 | _LOGGER = logging.getLogger(__name__) 61 | 62 | ip_data_dict = {} 63 | socket_ip_dict = {} 64 | sockfda = {} 65 | 66 | def setup(hass, config): 67 | global weathe_code 68 | """Set up platform using YAML.""" 69 | night_start_hour = config[DOMAIN].get(CONF_NIGHT_START_HOUR) 70 | night_end_hour = config[DOMAIN].get(CONF_NIGHT_END_HOUR) 71 | is_night_update = config[DOMAIN].get(CONF_IS_NIGHT_UPDATE) 72 | scan_interval = config[DOMAIN].get(CONF_SCAN_INTERVAL) 73 | weathe_code = config[DOMAIN].get(CONF_WEATHE_CODE) 74 | 75 | run_weather = threading.Thread(target=func_weather) #新建天气循环线程 76 | run_weather.start() 77 | 78 | server = AirnutSocketServer(night_start_hour, night_end_hour, is_night_update, scan_interval,weathe_code,config) 79 | 80 | hass.data[DOMAIN] = { 81 | 'server': server 82 | } 83 | 84 | return True 85 | 86 | 87 | 88 | def func_weather(): 89 | global weathestate 90 | global weathe_status 91 | global weathe_code 92 | errcount = 0 93 | wet_dataA={"晴":0,"阴":1,"多云":1,"雨":3,"阵雨":3,"雷阵雨":3,"雷阵雨伴有冰雹":3,"雨夹雪":6,"小雨":3,"中雨":3,"大雨":3,"暴雨":3,"大暴雨":3,"特大暴雨":3,"阵雪":5,"小雪":5,"中雪":5,"大雪":5,"暴雪":5,"雾":2,"冻雨":6,"沙尘暴":2,"小雨转中雨":3,"中雨转大雨":3,"大雨转暴雨":3,"暴雨转大暴雨":3,"大暴雨转特大暴雨":3,"小雪转中雪":5,"中雪转大雪":5,"大雪转暴雪":5,"浮沉":2,"扬沙":2,"强沙尘暴":2,"霾":2} 94 | header = {'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36'} 95 | while True: 96 | datayesorno = False 97 | try: 98 | res = requests.get('https://api.help.bj.cn/apis/weather/?id='+str(weathe_code),headers=header) 99 | #print('https://api.help.bj.cn/apis/weather/?id='+str(weathe_code)) 100 | res.encoding='utf-8' 101 | if res.status_code==200: 102 | jsonData = res.json() 103 | jsonwt = jsonData['weather'] 104 | datayesorno = True 105 | #print(jsonData) 106 | except: 107 | continue 108 | if datayesorno: 109 | print(jsonData['weather']) 110 | weathe_status = jsonData['weather'] 111 | try: 112 | weathestate = wet_dataA[jsonData['weather']] 113 | except: 114 | continue 115 | errcount = 0 116 | time.sleep(600) 117 | else: 118 | errcount = errcount + 1 119 | if errcount >= 3: 120 | errcount = 0 121 | #print(res.text()) 122 | time.sleep(600) 123 | else: 124 | time.sleep(30) 125 | 126 | 127 | 128 | def get_time(): 129 | return (datetime.datetime.utcnow() + datetime.timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S") 130 | 131 | def get_time_unix(): 132 | return int((datetime.datetime.now() + datetime.timedelta(hours=8)).timestamp()) 133 | 134 | async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): 135 | hass.async_create_task( 136 | hass.config_entries.async_forward_entry_setup(entry, "sensor") 137 | ) 138 | return True 139 | 140 | async def async_unload_entry(hass, entry): 141 | """Unload a config entry.""" 142 | hass.config_entries.async_forward_entry_unload(entry, "sensor") 143 | 144 | await hass.async_add_executor_job(hass.data[DOMAIN]['server'].unload) 145 | 146 | return True 147 | 148 | class AirnutSocketServer: 149 | 150 | def __init__(self, night_start_hour, night_end_hour, is_night_update, scan_interval,weathe_code,config): 151 | self._lastUpdateTime = ZERO_TIME 152 | self._night_start_hour = night_start_hour.strftime("%H%M%S") 153 | self._night_end_hour = night_end_hour.strftime("%H%M%S") 154 | self._is_night_update = is_night_update 155 | self._scan_interval = scan_interval 156 | self._weathe_code = weathe_code 157 | self._config = config 158 | 159 | self._socketServer = socket(AF_INET, SOCK_STREAM) 160 | self._socketServer.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 161 | try: 162 | self._socketServer.bind((HOST_IP, 10512)) 163 | self._socketServer.listen(5) 164 | except OSError as e: 165 | _LOGGER.error("server got %s", e) 166 | pass 167 | 168 | global socket_ip_dict 169 | socket_ip_dict[self._socketServer] = HOST_IP 170 | 171 | _LOGGER.debug("socket Server loaded") 172 | self.update() 173 | 174 | def get_state(self): 175 | return "new" 176 | 177 | def object_to_json_data(self, object): 178 | return json.dumps(object).encode('utf-8') 179 | 180 | def json_string_to_object(self, data): 181 | try: 182 | return json.loads(data) 183 | except: 184 | return None 185 | 186 | def update(self): 187 | global socket_ip_dict 188 | 189 | read_sockets, write_sockets, error_sockets = select.select(socket_ip_dict.keys(), [], [], 0) 190 | 191 | self.deal_error_sockets(error_sockets) 192 | self.deal_read_sockets(read_sockets) 193 | 194 | now_time = datetime.datetime.now() 195 | if now_time - self._lastUpdateTime < self._scan_interval: 196 | return True 197 | 198 | self._lastUpdateTime = now_time 199 | 200 | now_time_str = datetime.datetime.now().strftime("%H%M%S") 201 | if ((self._is_night_update is False) and 202 | (self._night_start_hour < now_time_str or self._night_end_hour > now_time_str)): 203 | return True 204 | 205 | self.deal_write_sockets(socket_ip_dict.keys()) 206 | 207 | return True 208 | 209 | def deal_error_sockets(self, error_sockets): 210 | global socket_ip_dict 211 | for sock in error_sockets: 212 | del socket_ip_dict[sock] 213 | 214 | def deal_read_sockets(self, read_sockets): 215 | #接收数据 216 | detect_msg = {"common": {"device": "Fun_pm25", "protocol": "detect"}, "param": {"fromport": 8023, "airid": 1010695, "fromhost": "one"}} 217 | global weathestate 218 | check_msg = {"common": {"code": 0, "protocol": "get_weather"}, "param": {"weather": "weathercode", "time": get_time_unix()}} 219 | check_msg=json.dumps(check_msg) 220 | check_msg=check_msg.replace("weathercode",str(weathestate)) 221 | check_msg=json.loads(check_msg) 222 | 223 | global ip_data_dict 224 | global sockfda 225 | for sock in read_sockets: 226 | if sock == self._socketServer: 227 | _LOGGER.info("going to accept new connection") 228 | try: 229 | sockfd, (host, _) = sock.accept() 230 | sockfda = sockfd 231 | socket_ip_dict[sockfd] = host 232 | _LOGGER.info("Client (%s) connected", socket_ip_dict[sockfd]) 233 | try: 234 | #连接首先发送一次对时 235 | sockfd.send(self.object_to_json_data(check_msg)) 236 | time.sleep(15) 237 | sockfd.send(self.object_to_json_data(detect_msg)) 238 | except OSError as e: 239 | _LOGGER.error("Client error 1 %s", e) 240 | sockfd.shutdown(2) 241 | sockfd.close() 242 | del socket_ip_dict[sockfd] 243 | continue 244 | 245 | except OSError: 246 | _LOGGER.warning("Client accept failed") 247 | continue 248 | else: 249 | originData = None 250 | try: 251 | originData = sock.recv(1024) 252 | _LOGGER.debug("Receive originData %s", originData) 253 | except OSError as e: 254 | _LOGGER.warning("Processing Client error 2 %s", e) 255 | continue 256 | if originData: 257 | datas = originData.decode('utf-8').split("\n\r") 258 | for singleData in datas: 259 | jsonData = self.json_string_to_object(singleData) 260 | if (jsonData is not None and 261 | jsonData["common"]["protocol"] == "login"): 262 | sock.send(self.object_to_json_data({"common": {"data": {}, "code": 0, "protocol": "login"}})) 263 | if (jsonData is not None and 264 | jsonData["common"]["protocol"] == "post"): 265 | global weathe_status 266 | ip_data_dict[socket_ip_dict[sock]] = { 267 | ATTR_PM25: int(jsonData["param"]["pm25"]), 268 | ATTR_TEMPERATURE: format(float(jsonData["param"]["t"]), '.1f'), 269 | ATTR_HUMIDITY: format(float(jsonData["param"]["h"]), '.1f'), 270 | ATTR_BATTERY: int(jsonData["param"]["battery"]), 271 | ATTR_WEATHE: weathe_status, 272 | ATTR_TIME: get_time(), 273 | } 274 | _LOGGER.debug("ip_data_dict %s", ip_data_dict) 275 | if (jsonData is not None and 276 | jsonData["common"]["protocol"] == "heartbeat"): 277 | #心跳一次更新一次时间和天气 278 | sock.send(self.object_to_json_data(check_msg)) 279 | continue 280 | def deal_write_sockets(self, write_sockets): 281 | #发送数据 282 | #持续检测,持续亮屏,风扇问题晚上很吵 ,调整 SCAN_INTERVAL = datetime.timedelta(seconds=60) 里面的数字 283 | global socket_ip_dict 284 | global weathestate 285 | check_msg = {"common": {"device": "Fun_pm25", "protocol": "detect"}, "param": {"fromport": 8023, "airid": 1010695, "fromhost": "interval"}} 286 | for sock in write_sockets: 287 | if sock == self._socketServer: 288 | continue 289 | try: 290 | sock.send(self.object_to_json_data(check_msg)) 291 | except: 292 | del socket_ip_dict[sock] 293 | 294 | 295 | def get_data(self, ip): 296 | try: 297 | global ip_data_dict 298 | return ip_data_dict[ip] 299 | except: 300 | return {} 301 | 302 | def unload(self): 303 | """Signal shutdown of sock.""" 304 | _LOGGER.info("AirnutSensor Sock close") 305 | self._socketServer.shutdown(2) 306 | self._socketServer.close() 307 | -------------------------------------------------------------------------------- /custom_components/airnut/const.py: -------------------------------------------------------------------------------- 1 | """ Constants """ 2 | # Base component constants 3 | DOMAIN = "airnut" 4 | VERSION = "1.0.1" 5 | ISSUE_URL = "https://github.com/billhu1996/Airnut/issues" 6 | ISSUE_URL2 = "https://github.com/zllikey/AirnutFun/issues" 7 | ATTRIBUTION = "" 8 | 9 | # Configuration 10 | ATTR_TEMPERATURE = "temperature" 11 | ATTR_HUMIDITY = "humidity" 12 | ATTR_PM25 = "pm25" 13 | ATTR_BATTERY = "battery" 14 | ATTR_VOLUME = "volume" 15 | ATTR_TIME = "time" 16 | ATTR_WEATHE = "weathe" 17 | 18 | #Unit 19 | MEASUREMENT_UNITE_DICT = { 20 | ATTR_TEMPERATURE: "°C", 21 | ATTR_HUMIDITY: "%", 22 | ATTR_PM25: "μg/m³", 23 | ATTR_BATTERY: "%", 24 | ATTR_WEATHE: None 25 | } 26 | 27 | # Defaults 28 | DEFAULT_SCAN_INTERVAL = 600 29 | -------------------------------------------------------------------------------- /custom_components/airnut/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "airnut", 3 | "name": "Airnut Fun", 4 | "documentation": "https://github.com/zllikey/AirnutFun", 5 | "dependencies": [], 6 | "codeowners": [], 7 | "requirements": [], 8 | "version": "2.0.8" 9 | } 10 | -------------------------------------------------------------------------------- /custom_components/airnut/sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Support for Airnut Fun plant sensor. 3 | Developer by billhu 4 | modify by zllikey 5 | 10.0.0.10 apn.airnut.com apn2.airnut.com 6 | """ 7 | import logging 8 | import datetime 9 | import voluptuous as vol 10 | from homeassistant.components.sensor import PLATFORM_SCHEMA 11 | from homeassistant.helpers.entity import Entity 12 | import homeassistant.helpers.config_validation as cv 13 | from homeassistant.const import CONF_NAME 14 | 15 | from .const import ( 16 | DOMAIN, 17 | ATTR_TEMPERATURE, 18 | ATTR_HUMIDITY, 19 | ATTR_PM25, 20 | ATTR_BATTERY, 21 | ATTR_WEATHE, 22 | ATTR_VOLUME, 23 | ATTR_TIME, 24 | MEASUREMENT_UNITE_DICT, 25 | ) 26 | 27 | CONF_TYPE = "type" 28 | CONF_IP = "ip" 29 | DEFAULT_NAME = 'Airnut Fun' 30 | 31 | _LOGGER = logging.getLogger(__name__) 32 | 33 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 34 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 35 | vol.Required(CONF_IP): cv.string, 36 | vol.Required(CONF_TYPE): vol.Any(ATTR_TEMPERATURE, ATTR_HUMIDITY, ATTR_PM25, ATTR_BATTERY,ATTR_WEATHE), 37 | }) 38 | 39 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 40 | """Setup the sensor platform.""" 41 | if DOMAIN in hass.data: 42 | server = hass.data[DOMAIN]['server'] 43 | async_add_entities([AirnutSensor(config, server)],True) 44 | 45 | async def async_setup_entry(hass, entry, async_add_entities): 46 | """Setup sensor platform.""" 47 | if DOMAIN in hass.data: 48 | server = hass.data[DOMAIN]['server'] 49 | async_add_entities( 50 | [AirnutSensor(entry.data, server)], False, 51 | ) 52 | return True 53 | 54 | class AirnutSensor(Entity): 55 | """Implementing the Airnut 1S sensor.""" 56 | 57 | def __init__(self, config, server): 58 | """Initialize the sensor.""" 59 | _LOGGER.info("AirnutSensor __init__") 60 | self.config = config 61 | self._server = server 62 | self._type = config.get(CONF_TYPE) 63 | self._ip = config.get(CONF_IP) 64 | self._name = config.get(CONF_NAME) 65 | if self._name == DEFAULT_NAME: 66 | self._name += "_" + self._type 67 | self._icon = None 68 | self._state = None 69 | self._state_attrs = { 70 | self._type: None, 71 | ATTR_TIME: None, 72 | } 73 | # await self.hass.async_add_executor_job(self.async_update) 74 | 75 | @property 76 | def unique_id(self): 77 | """Return a unique ID to use for this sensor.""" 78 | return self.config.get("unique_id", None) 79 | 80 | @property 81 | def name(self): 82 | """Return the name of the sensor.""" 83 | return self._name 84 | 85 | @property 86 | def state(self): 87 | """Return the state of the sensor.""" 88 | return self._state 89 | 90 | @property 91 | def device_state_attributes(self): 92 | """Return the state of the sensor.""" 93 | return self._state_attrs 94 | 95 | @property 96 | def unit_of_measurement(self): 97 | """Return the unit this state is expressed in.""" 98 | return MEASUREMENT_UNITE_DICT[self._type] 99 | 100 | async def async_added_to_hass(self): 101 | """Once the entity is added we should update to get the initial data loaded.""" 102 | self.async_schedule_update_ha_state(True) 103 | 104 | async def async_update(self): 105 | self.hass.async_add_executor_job(self._server.update) 106 | try: 107 | data = self._server.get_data(self._ip) 108 | self._state = data[self._type] 109 | self._state_attrs[self._type] = self._state 110 | self._state_attrs[ATTR_TIME] = data[ATTR_TIME] 111 | except: 112 | _LOGGER.info("AirnutSensor get data error with ip %s", self._ip) 113 | return 114 | --------------------------------------------------------------------------------