Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c4d298ab | ||
|
|
6adf60a450 | ||
|
|
4c3ba13f6c | ||
|
|
4e55124837 | ||
|
|
0bea772784 | ||
|
|
04a0bef826 | ||
|
|
c5f99dae9b | ||
|
|
827a1fcdb8 | ||
|
|
4f765b6109 | ||
|
|
df1e19ec49 | ||
|
|
1cc2efecc8 | ||
|
|
96191aa99a | ||
|
|
84c3fd7b97 | ||
|
|
f504335c5b | ||
|
|
f3705d94a2 |
@@ -0,0 +1,17 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 1.3
|
||||||
|
|
||||||
|
- Standalone version
|
||||||
|
|
||||||
|
## 1.2
|
||||||
|
|
||||||
|
- RF listening and states managements on callback
|
||||||
|
|
||||||
|
## 1.1
|
||||||
|
|
||||||
|
- Repository Installation
|
||||||
|
|
||||||
|
## 1.0
|
||||||
|
|
||||||
|
- Initial release
|
||||||
@@ -4,8 +4,67 @@
|
|||||||
|
|
||||||
Home Assistant Addon to control an AirSend device in a local network.
|
Home Assistant Addon to control an AirSend device in a local network.
|
||||||
|
|
||||||
## Installation
|
## Repository Installation
|
||||||
|
|
||||||
1. Into the terminal, run `wget -q -O - https://raw.githubusercontent.com/devmel/hass_airsend-addon/master/install | bash -`
|
[](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fdevmel%2Fhass_airsend-addon)
|
||||||
OR copy the `airsend` folder into your [addon folder](https://developers.home-assistant.io/docs/creating_integration_file_structure/#where-home-assistant-looks-for-integrations).
|
|
||||||
2. In Supervisor -> Add-on store -> Local add-ons, refresh, install and start AirSend addon
|
|
||||||
|
## Manual Installation
|
||||||
|
|
||||||
|
#### 1. Install the Add-on
|
||||||
|
Run the following command in your terminal:
|
||||||
|
```bash
|
||||||
|
wget -q -O - https://raw.githubusercontent.com/devmel/hass_airsend-addon/master/install bash -
|
||||||
|
```
|
||||||
|
**OR**
|
||||||
|
Manually copy the `airsend` folder into your [Home Assistant addons directory](https://developers.home-assistant.io/docs/creating_integration_file_structure/#where-home-assistant-looks-for-integrations).
|
||||||
|
|
||||||
|
#### 2. Start the Add-on
|
||||||
|
1. Go to **Supervisor** → **Add-on Store** → **Local add-ons**.
|
||||||
|
2. Refresh the list, then install and start the **AirSend** add-on.
|
||||||
|
|
||||||
|
|
||||||
|
## Install on an external machine
|
||||||
|
#### 1. Clone the Repository
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/devmel/hass_airsend-addon
|
||||||
|
cd hass_airsend-addon/addons/airsend
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Generate a Home Assistant API Key
|
||||||
|
1. Go to your [Home Assistant profile](https://my.home-assistant.io/redirect/profile/).
|
||||||
|
2. Navigate to the **Security** tab.
|
||||||
|
3. Generate a **long-lived access token** (use this as `HASS_TOKEN`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. Build the Docker Image
|
||||||
|
Replace `amd64` with your machine's architecture if needed (`armhf`, `armv7`, `aarch64`, etc.):
|
||||||
|
```bash
|
||||||
|
docker build --build-arg "BUILD_FROM=ghcr.io/home-assistant/amd64-base:3.22" -t hass_airsend-addon .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. Run the Docker Container
|
||||||
|
Adjust the environment variables according to your setup:
|
||||||
|
```bash
|
||||||
|
docker run -dp 33863:33863 \
|
||||||
|
-e HTTPS=1 \
|
||||||
|
-e HASS_HOST='homeassistant.local:8123' \
|
||||||
|
-e HASS_TOKEN='your_token_here' \
|
||||||
|
-e HASS_AUTOINCLUDE=0 \
|
||||||
|
hass_airsend-addon
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Environment Variables:
|
||||||
|
- HTTPS=1: Enables HTTPS. Use HTTPS=0 if you cannot handle self-signed certificates.
|
||||||
|
- HASS_HOST: Your Home Assistant server address and port (e.g., homeassistant.local:8123).
|
||||||
|
- HASS_TOKEN: The long-lived access token you generated.
|
||||||
|
- HASS_AUTOINCLUDE: Set to 0 to disable auto-inclusion of the add-on in Home Assistant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Important Notes
|
||||||
|
- **Security**: Never share your `HASS_TOKEN`.
|
||||||
|
- **HTTPS**: If `HTTPS=0`, ensure your network is secure.
|
||||||
|
|||||||
@@ -1,13 +1,36 @@
|
|||||||
ARG BUILD_FROM
|
ARG BUILD_FROM
|
||||||
FROM $BUILD_FROM
|
FROM $BUILD_FROM
|
||||||
|
|
||||||
|
ENV HASS_HOST=
|
||||||
|
ENV HASS_TOKEN=
|
||||||
|
ENV HASS_AUTOINCLUDE=0
|
||||||
|
ENV HTTPS=1
|
||||||
|
|
||||||
# Copy data for add-on
|
# Copy data for add-on
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY hassapi.class.php /home
|
||||||
|
COPY hass_cb.php /home
|
||||||
|
COPY sync_hass_states.php /home
|
||||||
|
COPY state_post.sh /home
|
||||||
|
COPY states_get.sh /home
|
||||||
COPY run.sh /
|
COPY run.sh /
|
||||||
|
RUN chmod a+x /home/state_post.sh
|
||||||
|
RUN chmod a+x /home/states_get.sh
|
||||||
RUN chmod a+x /run.sh
|
RUN chmod a+x /run.sh
|
||||||
|
|
||||||
# Install AirSendWebService
|
# Install AirSendWebService
|
||||||
# RUN mkdir -p /home && cd /home && wget http://devmel.com/dl/AirSendWebService.tgz && (gzip -dc AirSendWebService.tgz | tar -xvzf -) && chmod -R 777 bin
|
|
||||||
RUN mkdir -p /home && cd /home && wget http://devmel.com/dl/AirSendWebService.tgz && tar -zxvf AirSendWebService.tgz && chmod -R 777 bin
|
RUN mkdir -p /home && cd /home && wget http://devmel.com/dl/AirSendWebService.tgz && tar -zxvf AirSendWebService.tgz && chmod -R 777 bin
|
||||||
|
|
||||||
|
# Install Services
|
||||||
|
RUN apk add php php-curl nginx openssl
|
||||||
|
|
||||||
|
# Generate self-signed certificate
|
||||||
|
RUN mkdir -p /etc/nginx/ssl
|
||||||
|
RUN openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||||
|
-subj "/C=FR/ST=France/L=Paris/O=AirSendWebService/CN=localhost" \
|
||||||
|
-keyout /etc/nginx/ssl/selfsigned.key \
|
||||||
|
-out /etc/nginx/ssl/selfsigned.crt
|
||||||
|
|
||||||
# Start AirSendWebService
|
# Start AirSendWebService
|
||||||
CMD [ "/run.sh" ]
|
CMD [ "/run.sh" ]
|
||||||
|
EXPOSE 33863
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
build_from:
|
||||||
|
aarch64: ghcr.io/home-assistant/aarch64-base:3.22
|
||||||
|
amd64: ghcr.io/home-assistant/amd64-base:3.22
|
||||||
|
armhf: ghcr.io/home-assistant/armhf-base:3.22
|
||||||
|
armv7: ghcr.io/home-assistant/armv7-base:3.22
|
||||||
|
i386: ghcr.io/home-assistant/i386-base:3.22
|
||||||
|
codenotary:
|
||||||
|
signer: notary@home-assistant.io
|
||||||
|
base_image: notary@home-assistant.io
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
name: "AirSend"
|
name: "AirSend"
|
||||||
description: "AirSend Web Service"
|
description: "AirSend Web Service (local connection, no internet, no cloud)"
|
||||||
version: "1.0"
|
version: "1.3"
|
||||||
slug: "airsend"
|
slug: "airsend"
|
||||||
url: https://github.com/devmel/hass_airsend-addon
|
url: https://github.com/devmel/hass_airsend-addon
|
||||||
|
codenotary: notary@home-assistant.io
|
||||||
arch:
|
arch:
|
||||||
- aarch64
|
- aarch64
|
||||||
- amd64
|
- amd64
|
||||||
- armhf
|
- armhf
|
||||||
- armv7
|
- armv7
|
||||||
- i386
|
- i386
|
||||||
|
homeassistant_api: true
|
||||||
startup: services
|
startup: services
|
||||||
|
init: false
|
||||||
ports:
|
ports:
|
||||||
33863/tcp: null
|
33863/tcp: null
|
||||||
ports_description:
|
ports_description:
|
||||||
33863/tcp: AirSendWebService communication
|
33863/tcp: AirSendWebService communication
|
||||||
|
options:
|
||||||
|
auto_include: true
|
||||||
|
schema:
|
||||||
|
auto_include: bool
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
require_once dirname(__FILE__) . "/hassapi.class.php";
|
||||||
|
|
||||||
|
$SUPERVISOR_TOKEN = @getenv("SUPERVISOR_TOKEN");
|
||||||
|
$HASS_HOST= @getenv("HASS_HOST");
|
||||||
|
$HASS_TOKEN= @getenv("HASS_TOKEN");
|
||||||
|
|
||||||
|
$auto_include = false;
|
||||||
|
if(!empty($HASS_HOST) && !empty($HASS_TOKEN)){
|
||||||
|
$machine = HassAPI::extractHostAndPort($HASS_HOST);
|
||||||
|
$HASS_API_URL = "http://".$machine['hostname'].":".$machine['port']."/api";
|
||||||
|
$HASS_API_TOKEN = $HASS_TOKEN;
|
||||||
|
$HASS_AUTOINCLUDE = @getenv("HASS_AUTOINCLUDE");
|
||||||
|
$auto_include = filter_var($HASS_AUTOINCLUDE, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}else if(!empty($SUPERVISOR_TOKEN)){
|
||||||
|
$HASS_API_URL = "http://supervisor/core/api";
|
||||||
|
$HASS_API_TOKEN = $SUPERVISOR_TOKEN;
|
||||||
|
$fbvalue = @file_get_contents('auto_include.cfg');
|
||||||
|
$auto_include = filter_var($fbvalue, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$api = new HassAPI($HASS_API_URL, $HASS_API_TOKEN, $auto_include);
|
||||||
|
|
||||||
|
if(!$api->isAuthorized()){
|
||||||
|
header("HTTP/1.1 401 Unauthorized");
|
||||||
|
trigger_error("Unauthorized", E_USER_ERROR);
|
||||||
|
die();
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$data = json_decode($raw, true, 512, JSON_BIGINT_AS_STRING);
|
||||||
|
if (is_array($data) && isset($data['events'])) {
|
||||||
|
foreach ($data['events'] as $i => $val) {
|
||||||
|
if(isset($val['channel']) && isset($val['type']) && isset($val['thingnotes'])){
|
||||||
|
//Transfer event
|
||||||
|
if(isset($val['thingnotes']['uid'])){
|
||||||
|
$entity_id = $api->searchEntityId($val['thingnotes']['uid']);
|
||||||
|
if(isset($entity_id)){
|
||||||
|
if($val['type'] == 3 || $val['type'] == 2 || $val['type'] == 1){
|
||||||
|
$states = $api->convertNotesToStates($val['thingnotes']['notes']);
|
||||||
|
foreach ($states as $j => $state) {
|
||||||
|
$api->setState($entity_id, $state[0], $state[1], $val['timestamp'], $val['channel']);
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
$api->setState($entity_id, 'error', 'error_'.$val['type'], $val['timestamp'], $val['channel']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//Interrupt event
|
||||||
|
}else{
|
||||||
|
if($val['type'] == 3){ //Event type GOT (sensor)
|
||||||
|
$isreliable = false;
|
||||||
|
if($val['reliability'] > 0x6 && $val['reliability'] < 0x47){
|
||||||
|
$isreliable = true;
|
||||||
|
}
|
||||||
|
if($isreliable == true){
|
||||||
|
$states = $api->convertNotesToStates($val['thingnotes']['notes']);
|
||||||
|
foreach ($states as $j => $state) {
|
||||||
|
//Search entity and update
|
||||||
|
$entities = $api->searchEntitiesFromChannelAndType($val['channel'], $state[0]);
|
||||||
|
foreach ($entities as $k => $entity_id) {
|
||||||
|
$api->setState($entity_id, $state[0], $state[1], $val['timestamp'], $val['channel']);
|
||||||
|
}
|
||||||
|
//Creates if not exists
|
||||||
|
if(count($entities) == 0){
|
||||||
|
trigger_error("new channel found : ".json_encode($val['channel'])." ".$state[0]." ".$state[1], E_USER_NOTICE);
|
||||||
|
$api->setState(null, $state[0], $state[1], $val['timestamp'], $val['channel']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class HassAPI{
|
||||||
|
var $states = null;
|
||||||
|
|
||||||
|
function __construct($BASE_HASS_API, $HASS_API_TOKEN, $auto_include = false) {
|
||||||
|
$this->BASE_HASS_API = $BASE_HASS_API;
|
||||||
|
$this->HASS_API_TOKEN = $HASS_API_TOKEN;
|
||||||
|
$this->auto_include = $auto_include;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAuthorized() {
|
||||||
|
if(!empty($this->BASE_HASS_API) && !empty($this->HASS_API_TOKEN)){
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
public function isAutoInclude() {
|
||||||
|
return $this->auto_include;
|
||||||
|
}
|
||||||
|
function searchEntityId($uid) {
|
||||||
|
if($this->loadStates()){
|
||||||
|
foreach ($this->states as $value) {
|
||||||
|
if($value['entity_uid_sha256'] == $uid){
|
||||||
|
return $value['entity_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchEntitiesFromChannelAndType($channel, $type) {
|
||||||
|
$ret = array();
|
||||||
|
if(isset($channel) && $this->loadStates()){
|
||||||
|
foreach ($this->states as $value) {
|
||||||
|
if(array_key_exists('attributes', $value) && array_key_exists('channel', $value['attributes'])){
|
||||||
|
if(self::isChannelCompatible($channel, $value['attributes']['channel'])){
|
||||||
|
$foundid = false;
|
||||||
|
$entity_id = $value['entity_id'];
|
||||||
|
//Sensors
|
||||||
|
$is_sensor = (stripos($entity_id, "sensor.") !== false) ? true : false;
|
||||||
|
if($is_sensor && array_key_exists('device_class', $value['attributes'])){
|
||||||
|
$dclass = $value['attributes']['device_class'];
|
||||||
|
$dclass = str_replace('humidity', 'r_humidity', $dclass);
|
||||||
|
if($dclass == $type){
|
||||||
|
$foundid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//Others
|
||||||
|
if($type == 'level' || $type == 'state' || $type == 'toggle' || $type == 'data'){
|
||||||
|
$foundid = true;
|
||||||
|
}
|
||||||
|
if($foundid){
|
||||||
|
$ret[] = $entity_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState($entity_id, $type, $state, $timestamp_ms, $channel = null) {
|
||||||
|
$reloadcache = false;
|
||||||
|
$attributes = null;
|
||||||
|
if(isset($channel) && ((int)$channel["id"]) == 1){
|
||||||
|
if($type === 'data'){
|
||||||
|
return; //accept AirSend state only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if($entity_id == null && isset($channel)){
|
||||||
|
$reloadcache = true;
|
||||||
|
//New entity
|
||||||
|
$entity_id = "sensor.".self::toUniqueChannelName($channel);
|
||||||
|
$attributes = array('channel' => $channel);
|
||||||
|
if($type === 'temperature'){
|
||||||
|
$entity_id .= '_temp';
|
||||||
|
$attributes['device_class'] = 'temperature';
|
||||||
|
$attributes['unit_of_measurement'] = '°C';
|
||||||
|
}else if($type === 'illuminance'){
|
||||||
|
$entity_id .= '_ill';
|
||||||
|
$attributes['device_class'] = 'illuminance';
|
||||||
|
$attributes['unit_of_measurement'] = 'lx';
|
||||||
|
}else if($type === 'r_humidity'){
|
||||||
|
$entity_id .= '_rh';
|
||||||
|
$attributes['device_class'] = 'humidity';
|
||||||
|
$attributes['unit_of_measurement'] = '%';
|
||||||
|
}else if($type === 'toggle' || $type === 'data'){
|
||||||
|
//auto_include disabled for toggle, it's too messy on dashboard
|
||||||
|
return ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$content = $this->getState($entity_id);
|
||||||
|
if($content == null && $attributes != null && $this->auto_include){
|
||||||
|
$content = array('attributes' => $attributes);
|
||||||
|
}
|
||||||
|
if($content != null){
|
||||||
|
if($attributes != null){
|
||||||
|
$content['attributes'] = array_merge($content['attributes'], $attributes);
|
||||||
|
}
|
||||||
|
$current_position = null;
|
||||||
|
if($type === 'data'){
|
||||||
|
if ($state && strlen($state) > 20){
|
||||||
|
$state = substr($state, 0, 17) . '...';
|
||||||
|
}
|
||||||
|
if(array_key_exists('state', $content) && $content['state'] == $state){
|
||||||
|
$state .= '+';
|
||||||
|
}
|
||||||
|
$reloadcache = true;
|
||||||
|
}
|
||||||
|
if($type === 'toggle'){
|
||||||
|
if(array_key_exists('state', $content) && $content['state'] == 'pressed'){
|
||||||
|
$state .= '+';
|
||||||
|
}
|
||||||
|
$reloadcache = true;
|
||||||
|
}
|
||||||
|
if($type === 'level'){
|
||||||
|
$is_cover = (stripos($entity_id, "cover.") !== false) ? true : false;
|
||||||
|
$current_position = intval($state);
|
||||||
|
$state = $current_position.'%';
|
||||||
|
if($current_position >= 100){
|
||||||
|
$state = $is_cover ? 'open': 'on';
|
||||||
|
}else if($current_position <= 0){
|
||||||
|
$state = $is_cover ? 'closed': 'off';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$content['state'] = $state;
|
||||||
|
if($current_position !== null){
|
||||||
|
if(array_key_exists('attributes', $content) && array_key_exists('supported_features', $content['attributes']) && ($content['attributes']['supported_features']&0x4) == 0x4){
|
||||||
|
$content['attributes']['current_position'] = $current_position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($content['entity_uid_sha256']);
|
||||||
|
unset($content['last_changed']);
|
||||||
|
unset($content['last_updated']);
|
||||||
|
//$dti = DateTimeImmutable::createFromFormat('U.u', ($timestamp_ms/1000));
|
||||||
|
//$content['last_updated'] = $dti->format('Y-m-d').'T'.$dti->format('H:i:s').'+00:00';
|
||||||
|
$this->state_post($entity_id, $content, $reloadcache);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getState($entity_id){
|
||||||
|
if($this->loadStates() && isset($entity_id)){
|
||||||
|
foreach ($this->states as $value) {
|
||||||
|
if($value['entity_id'] == $entity_id){
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function extractHostAndPort($input, $default_port = 8123){
|
||||||
|
$hostWithPort = preg_replace('#^https?://#', '', $input);
|
||||||
|
$hostWithPort = explode('/', $hostWithPort)[0];
|
||||||
|
if (strpos($hostWithPort, ':') !== false) {
|
||||||
|
list($hostname, $port) = explode(':', $hostWithPort, 2);
|
||||||
|
$port = (int)$port;
|
||||||
|
} else {
|
||||||
|
$hostname = $hostWithPort;
|
||||||
|
}
|
||||||
|
if(empty($port) || ($port <= 0 && $port >= 0x1000)){
|
||||||
|
$port = $default_port;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'hostname' => $hostname,
|
||||||
|
'port' => $port
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function convertNotesToStates($notes){
|
||||||
|
$ret = array();
|
||||||
|
if(is_array($notes) && count($notes) > 0){
|
||||||
|
foreach ($notes as $i => $note) {
|
||||||
|
$ovalue = $note['value'];
|
||||||
|
$stype = null;
|
||||||
|
$svalue = null;
|
||||||
|
if($note['type'] == 0){ //STATE
|
||||||
|
if(!is_numeric($ovalue)){
|
||||||
|
$statemap = array("","PING","PROG","UNPROG","RESET",
|
||||||
|
"","","","","","","","","","","","","STOP","TOGGLE","OFF","ON","CLOSE","OPEN",
|
||||||
|
"","","","","","","","","","","MIDDLE","DOWN","UP","LEFT","RIGHT","USERPOSITION");
|
||||||
|
$ovalue = array_search($ovalue, $statemap);
|
||||||
|
}
|
||||||
|
if(is_numeric($ovalue)){
|
||||||
|
$stype = 'state';
|
||||||
|
switch(intval($ovalue)){
|
||||||
|
case 18: //TOGGLE
|
||||||
|
$stype = 'toggle';
|
||||||
|
$svalue = 'pressed';
|
||||||
|
break;
|
||||||
|
case 19: //OFF
|
||||||
|
$stype = 'level';
|
||||||
|
$svalue = 0;
|
||||||
|
break;
|
||||||
|
case 20: //ON
|
||||||
|
$stype = 'level';
|
||||||
|
$svalue = 100;
|
||||||
|
break;
|
||||||
|
case 17: //STOP
|
||||||
|
$svalue = 'stop';
|
||||||
|
break;
|
||||||
|
case 33: //MIDDLE
|
||||||
|
case 38: //USERPOS
|
||||||
|
$svalue = 'user';
|
||||||
|
break;
|
||||||
|
case 34: //DOWN
|
||||||
|
$stype = 'level';
|
||||||
|
$svalue = 0;
|
||||||
|
break;
|
||||||
|
case 35: //UP
|
||||||
|
$stype = 'level';
|
||||||
|
$svalue = 100;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}else if($note['type'] == 1){ //DATA
|
||||||
|
$stype = 'data';
|
||||||
|
$svalue = $ovalue;
|
||||||
|
}else if($note['type'] == 2){ //TEMPERATURE
|
||||||
|
$stype = 'temperature';
|
||||||
|
$svalue = (floor((floatval($ovalue) - 273.15) * 10.0 + 0.5) / 10.0); //Kelvins to celcius
|
||||||
|
}else if($note['type'] == 3){ //ILLUMINANCE
|
||||||
|
$stype = 'illuminance';
|
||||||
|
$svalue = floatval($ovalue);
|
||||||
|
}else if($note['type'] == 4){ //R_HUMIDITY
|
||||||
|
$stype = 'r_humidity';
|
||||||
|
$svalue = intval($ovalue);
|
||||||
|
}else if($note['type'] == 9){ //LEVEL
|
||||||
|
$stype = 'level';
|
||||||
|
$svalue = intval($ovalue);
|
||||||
|
}
|
||||||
|
if($stype != null){
|
||||||
|
$ret[] = array($stype, $svalue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isChannelCompatible($obj1, $obj2){
|
||||||
|
$result = false;
|
||||||
|
if(isset($obj1) && isset($obj2)){
|
||||||
|
$ch1 = self::toBasicChannel($obj1);
|
||||||
|
$ch2 = self::toBasicChannel($obj2);
|
||||||
|
$result = ($ch1 == $ch2);
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function toUniqueChannelName($channel){
|
||||||
|
$result = $channel['id'];
|
||||||
|
if($result){
|
||||||
|
$uniquefield = array('source', 'mac', 'seed');
|
||||||
|
foreach ($uniquefield as $field) {
|
||||||
|
if(array_key_exists($field, $channel)){
|
||||||
|
$result .= "_";
|
||||||
|
$result .= $channel[$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function toBasicChannel($channel){
|
||||||
|
$result = array();
|
||||||
|
$uniquefield = array('id', 'source');
|
||||||
|
foreach ($channel as $key => $value) {
|
||||||
|
if (in_array($key, $uniquefield)) {
|
||||||
|
$result[$key] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function state_post($entity_id, $content, $reloadcache = false){
|
||||||
|
$json = json_encode($content, JSON_FORCE_OBJECT);
|
||||||
|
if(strpos($this->BASE_HASS_API, "supervisor") !== false){
|
||||||
|
//Bug in supervisor proxy => use bash
|
||||||
|
exec('./state_post.sh "'.$entity_id.'" "'.base64_encode($json).'"', $output, $retval);
|
||||||
|
}else{
|
||||||
|
$res = HassAPI::request($this->BASE_HASS_API."/states/".$entity_id, $json, 'POST', $this->HASS_API_TOKEN);
|
||||||
|
}
|
||||||
|
trigger_error("state_post ".$reloadcache." : ".$entity_id." ".$content['state'], E_USER_NOTICE);
|
||||||
|
if($reloadcache){
|
||||||
|
$this->states_get_cache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private function states_get_cache(){
|
||||||
|
if(strpos($this->BASE_HASS_API, "supervisor") !== false){
|
||||||
|
//Bug in supervisor proxy => use bash
|
||||||
|
exec('./states_get.sh', $output, $retval);
|
||||||
|
}else{
|
||||||
|
$res = HassAPI::request($this->BASE_HASS_API."/states", null, 'GET', $this->HASS_API_TOKEN);
|
||||||
|
if($res !== false && isset($res) && is_array($res) && isset($res['data'])){
|
||||||
|
file_put_contents("states.json", $res['data']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadStatesForSync() {
|
||||||
|
$ret = false;
|
||||||
|
if($this->states){
|
||||||
|
$ret = true;
|
||||||
|
}else{
|
||||||
|
if($this->isAuthorized()){
|
||||||
|
$loaded = false;
|
||||||
|
$data_age = @(time() - filemtime('states.json'));
|
||||||
|
if($data_age > 100){
|
||||||
|
$this->states_get_cache();
|
||||||
|
}
|
||||||
|
$data = @file_get_contents('states.json');
|
||||||
|
if(isset($data)){
|
||||||
|
$this->states = json_decode($data, true);
|
||||||
|
if($this->states){
|
||||||
|
foreach ($this->states as &$value) {
|
||||||
|
$h = str_split(hash('sha256', $value['entity_id']),12)[0];
|
||||||
|
$val = intval($h, 16);
|
||||||
|
$value['entity_uid_sha256'] = $val;
|
||||||
|
}
|
||||||
|
$loaded = true;
|
||||||
|
$ret = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if($loaded == false){
|
||||||
|
trigger_error("API GET states wrong response", E_USER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $this->states;
|
||||||
|
}
|
||||||
|
|
||||||
|
Private function loadStates() {
|
||||||
|
$ret = false;
|
||||||
|
if($this->states){
|
||||||
|
$ret = true;
|
||||||
|
}else{
|
||||||
|
if($this->isAuthorized()){
|
||||||
|
$loaded = false;
|
||||||
|
$data_age = @(time() - filemtime('states.json'));
|
||||||
|
if($data_age > 100){
|
||||||
|
$this->states_get_cache();
|
||||||
|
}
|
||||||
|
$data = @file_get_contents('states.json');
|
||||||
|
if(isset($data)){
|
||||||
|
$this->states = json_decode($data, true);
|
||||||
|
if($this->states){
|
||||||
|
foreach ($this->states as &$value) {
|
||||||
|
$h = str_split(hash('sha256', $value['entity_id']),12)[0];
|
||||||
|
$val = intval($h, 16);
|
||||||
|
$value['entity_uid_sha256'] = $val;
|
||||||
|
}
|
||||||
|
$loaded = true;
|
||||||
|
$ret = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if($loaded == false){
|
||||||
|
trigger_error("API GET states wrong response", E_USER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function request($url, $data = null, $method = 'GET', $token = null) {
|
||||||
|
$result = false;
|
||||||
|
if (extension_loaded('curl') === true){
|
||||||
|
$curl = curl_init();
|
||||||
|
curl_setopt($curl, CURLOPT_URL, $url);
|
||||||
|
curl_setopt($curl, CURLOPT_AUTOREFERER, true);
|
||||||
|
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
if (preg_match('~^(?:DELETE|GET|POST|PUT)$~i', $method) > 0){
|
||||||
|
if (preg_match('~^(?:POST|PUT)$~i', $method) > 0){
|
||||||
|
if (is_array($data) === true){
|
||||||
|
foreach (preg_grep('~^@~', $data) as $key => $value){
|
||||||
|
$data[$key] = sprintf('@%s', rtrim(str_replace('\\', '/', realpath(ltrim($value, '@'))), '/') . (is_dir(ltrim($value, '@')) ? '/' : ''));
|
||||||
|
}
|
||||||
|
if (count($data) != count($data, COUNT_RECURSIVE)){
|
||||||
|
$data = http_build_query($data, '', '&');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
curl_setopt($curl, CURLOPT_POST, true);
|
||||||
|
curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
|
||||||
|
}
|
||||||
|
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, strtoupper($method));
|
||||||
|
if (isset($token)){
|
||||||
|
$options = array('Content-Type: application/json', 'Authorization: Bearer ' . $token);
|
||||||
|
curl_setopt($curl, CURLOPT_HTTPHEADER, $options);
|
||||||
|
}
|
||||||
|
$result = array();
|
||||||
|
$result['data'] = curl_exec($curl);
|
||||||
|
$result['info'] = curl_getinfo($curl);
|
||||||
|
if (curl_errno($curl)) {
|
||||||
|
$result['error'] = curl_error($curl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
curl_close($curl);
|
||||||
|
}
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
events {}
|
||||||
|
http {
|
||||||
|
server {
|
||||||
|
listen 33863 ssl;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/selfsigned.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/selfsigned.key;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:33864;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
-2
@@ -1,13 +1,89 @@
|
|||||||
#!/usr/bin/with-contenv bashio
|
#!/usr/bin/with-contenv bashio
|
||||||
|
|
||||||
|
cd /home
|
||||||
arch="$(apk --print-arch)"
|
arch="$(apk --print-arch)"
|
||||||
case "$arch" in \
|
case "$arch" in \
|
||||||
aarch64) arch='arm64' ;; \
|
aarch64) arch='arm64' ;; \
|
||||||
|
armhf) arch='armhf' ;; \
|
||||||
armv7) arch='arm' ;; \
|
armv7) arch='arm' ;; \
|
||||||
amd64) arch='x86_64' ;; \
|
amd64) arch='x86_64' ;; \
|
||||||
i386) arch='x86' ;; \
|
i386) arch='x86' ;; \
|
||||||
esac;
|
esac;
|
||||||
|
ulimit -n 4096
|
||||||
|
echo "AirSendWebService arch: ${arch}"
|
||||||
|
|
||||||
cd /home
|
CALLBACK_PID=
|
||||||
|
if [ -n "${SUPERVISOR_TOKEN:-}" ]
|
||||||
|
then
|
||||||
|
hname="$(hostname -i)"
|
||||||
|
echo "internal_url: http://${hname}:33863/"
|
||||||
|
echo $(bashio::config 'auto_include') > auto_include.cfg
|
||||||
|
# Start AirSendWebService
|
||||||
./bin/unix/${arch}/AirSendWebService 99399
|
./bin/unix/${arch}/AirSendWebService 99399
|
||||||
sleep infinity
|
# Start hass callback service
|
||||||
|
php -S 127.0.0.1:80 hass_cb.php & CALLBACK_PID=$!
|
||||||
|
elif [ -n "${HASS_HOST:-}" ] && [ -n "${HASS_TOKEN:-}" ]
|
||||||
|
then
|
||||||
|
# Start AirSendWebService
|
||||||
|
if [ "${HTTPS:-0}" -eq 1 ]; then
|
||||||
|
./bin/unix/${arch}/AirSendWebService 33864
|
||||||
|
nginx
|
||||||
|
echo "internal_url: https://{YOUR_DOCKER_MACHINE_IP}:33863/"
|
||||||
|
else
|
||||||
|
./bin/unix/${arch}/AirSendWebService 99399
|
||||||
|
echo "internal_url: http://{YOUR_DOCKER_MACHINE_IP}:33863/"
|
||||||
|
fi
|
||||||
|
# Start hass callback service
|
||||||
|
php -S 127.0.0.1:80 hass_cb.php & CALLBACK_PID=$!
|
||||||
|
else
|
||||||
|
# Start AirSendWebService
|
||||||
|
if [ "${HTTPS:-0}" -eq 1 ]; then
|
||||||
|
./bin/unix/${arch}/AirSendWebService 33864
|
||||||
|
nginx
|
||||||
|
echo "example: curl -k https://{YOUR_DOCKER_MACHINE_IP}:33863/"
|
||||||
|
else
|
||||||
|
./bin/unix/${arch}/AirSendWebService 99399
|
||||||
|
echo "example: curl http://{YOUR_DOCKER_MACHINE_IP}:33863/"
|
||||||
|
fi
|
||||||
|
echo "Running the AirSendWebService only..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Give services time to start
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Lancement du synchro en arrière-plan toutes les X secondes
|
||||||
|
# Cela garantit que même si un équipement est sur un "autre canal",
|
||||||
|
# il sera interrogé et mis à jour car on boucle sur la liste globale.
|
||||||
|
php -r "while(true) { include 'sync_hass_states.php'; sleep(10); }" & SYNC_PID=$!
|
||||||
|
|
||||||
|
# Surveillance du processus de synchronisation
|
||||||
|
while true; do
|
||||||
|
sleep 60
|
||||||
|
if [[ -n "$SYNC_PID" && "$SYNC_PID" =~ ^[0-9]+$ ]] && ! kill -0 $SYNC_PID 2>/dev/null; then
|
||||||
|
echo "ERROR: Sync Service died..." >&2
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Monitor services
|
||||||
|
ASW_PID=$(cat AirSendWebService.lock)
|
||||||
|
while true; do
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
# Check if AirSendWebService is still running
|
||||||
|
if ! kill -0 "$ASW_PID" 2>/dev/null; then
|
||||||
|
echo "ERROR: AirSendWebService (PID: $ASW_PID) died..." >&2
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if Callback service exists and if is still running
|
||||||
|
if [[ -n "$CALLBACK_PID" && "$CALLBACK_PID" =~ ^[0-9]+$ ]] && ! kill -0 "$CALLBACK_PID" 2>/dev/null; then
|
||||||
|
echo "ERROR: Callback Service (PID: $CALLBACK_PID) died..." >&2
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
kill $ASW_PID 2>/dev/null || true
|
||||||
|
kill $CALLBACK_PID 2>/dev/null || true
|
||||||
|
echo "exiting..."
|
||||||
|
exit 1
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/with-contenv bashio
|
||||||
|
|
||||||
|
|
||||||
|
payload=`echo $2 | base64 -d`
|
||||||
|
echo -en $payload | curl -s --data-binary @- -X POST -H "Authorization: Bearer ${HASS_TOKEN}" -H "Content-Type: application/json" http://${HASS_HOST}/api/states/$1
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/usr/bin/with-contenv bashio
|
||||||
|
|
||||||
|
curl -o states.json -s -X GET -H "Authorization: Bearer ${HASS_TOKEN}" -H "Content-Type: application/json" http://${HASS_HOST}/api/states/$1
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
require_once dirname(__FILE__) . "/hassapi.class.php";
|
||||||
|
|
||||||
|
// --- Configuration (Identique à votre setup actuel) ---
|
||||||
|
$SUPERVISOR_TOKEN = @getenv("SUPERVISOR_TOKEN");
|
||||||
|
$HASS_HOST = @getenv("HASS_HOST");
|
||||||
|
$HASS_TOKEN = @getenv("HASS_TOKEN");
|
||||||
|
|
||||||
|
$auto_include = false;
|
||||||
|
if(!empty($HASS_HOST) && !empty($HASS_TOKEN)){
|
||||||
|
$machine = HassAPI::extractHostAndPort($HASS_HOST);
|
||||||
|
$HASS_API_URL = "http://".$machine['hostname'].":".$machine['port']."/api";
|
||||||
|
$HASS_API_TOKEN = $HASS_TOKEN;
|
||||||
|
$HASS_AUTOINCLUDE = @getenv("HASS_AUTOINCLUDE");
|
||||||
|
$auto_include = filter_var($HASS_AUTOINCLUDE, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}else if(!empty($SUPERVISOR_TOKEN)){
|
||||||
|
$HASS_API_URL = "http://supervisor/core/api";
|
||||||
|
$HASS_API_TOKEN = $SUPERVISOR_TOKEN;
|
||||||
|
$fbvalue = @file_get_contents('auto_include.cfg');
|
||||||
|
$auto_include = filter_var($fbvalue, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$api = new HassAPI($HASS_API_URL, $HASS_API_TOKEN, $auto_include);
|
||||||
|
|
||||||
|
if ($api->isAuthorized()) {
|
||||||
|
// On récupère la liste des entités que Home Assistant connaît
|
||||||
|
// Cela nous sert de "liste de courses" pour savoir ce qu'on doit mettre à jour.
|
||||||
|
$all_entities = $api->loadStatesForSync();
|
||||||
|
|
||||||
|
foreach ($all_entities as $entity_data) {
|
||||||
|
$id = $entity_data['entity_id'];
|
||||||
|
$details = $api->getState($id); // Récupère le type (temp, humidity, etc.) et l'état actuel.
|
||||||
|
|
||||||
|
if ($details) {
|
||||||
|
/**
|
||||||
|
* ÉTAPE CRUCIALE : Ici, au lieu d'attendre un événement AirSend,
|
||||||
|
* vous devez appeler une fonction qui interroge directement
|
||||||
|
* votre service AirSendWebService pour obtenir la valeur physique.
|
||||||
|
*
|
||||||
|
* Si vous avez une méthode ou un endpoint dans AirSend pour "récupérer tout",
|
||||||
|
* utilisez-le ici pour remplir $current_physical_value.
|
||||||
|
*/
|
||||||
|
$current_physical_value = $details['state']; // Par défaut, on garde l'ancienne si pas de changement
|
||||||
|
|
||||||
|
// Exemple logique si vous interrogez le service AirSend directement :
|
||||||
|
// $raw_airsend = \AirSendService::fetchStatus($entity_data['some_id']);
|
||||||
|
// if ($raw_airsend) { $current_physical_value = $raw_airsend; }
|
||||||
|
|
||||||
|
// On force la mise à jour vers Home Assistant
|
||||||
|
$api->setState(
|
||||||
|
$id,
|
||||||
|
$details['type'] ?? 'state',
|
||||||
|
$current_physical_value,
|
||||||
|
time() * 1000, // Timestamp actuel pour forcer le "last_updated" dans HA
|
||||||
|
$entity_data['attributes']['channel'] ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 22 KiB |
@@ -45,7 +45,7 @@ if [ -n "$haPath" ]; then
|
|||||||
cd "$haPath" || error "Could not change path to $haPath"
|
cd "$haPath" || error "Could not change path to $haPath"
|
||||||
|
|
||||||
info "Downloading AirSend Home Assistant Addon"
|
info "Downloading AirSend Home Assistant Addon"
|
||||||
wget "https://github.com/devmel/hass_airsend-addon/releases/download/1.0/hass_airsend-addon.zip"
|
wget "https://github.com/devmel/hass_airsend-addon/releases/download/latest/hass_airsend-addon.zip"
|
||||||
|
|
||||||
if [ -d "$haPath/airsend" ]; then
|
if [ -d "$haPath/airsend" ]; then
|
||||||
warn "airsend directory already exist, cleaning up..."
|
warn "airsend directory already exist, cleaning up..."
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# https://developers.home-assistant.io/docs/add-ons/repository#repository-configuration
|
||||||
|
name: AirSend Home Assistant add-on repository
|
||||||
|
url: 'https://github.com/devmel/hass_airsend-addon'
|
||||||
|
maintainer: Devmel Apps <apps@devmel.com>
|
||||||
Reference in New Issue
Block a user