Skip to content

Instantly share code, notes, and snippets.

@fracz
Last active July 27, 2021 10:21
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save fracz/9aa9923aec02db8b5ef5baf09113fa63 to your computer and use it in GitHub Desktop.
Save fracz/9aa9923aec02db8b5ef5baf09113fa63 to your computer and use it in GitHub Desktop.
SUPLA Events by @fracz

SUPLA Events (unofficial)

This is a SUPLA Cloud extension that enables basic events support. Installation includes changing some sources of SUPLA Cloud instance (yes, you need to have your own SUPLA Cloud, it cannot use it on a shared official platform at supla.org).

Event specification consist of a condition (what should happen in order for the event to be perceived as occured) and a webhooks, that is, URLs to call in such cases. The condition is a Twig expression with a simple state function provided. state(XX) returns a state of a channel with ID XX. The returned value depends on the channel function. You will find the example states for specific functions helpful. Take a look at example config.yml for different possibilities.

Event specification might also contain a time_conditions, that is cron expression (or array of them) specifying when the event should be active. If the time condition is not met, the condition is not checked at all so the webhook will not be fired.

If you use SUPLA Scripts and have public URLs for the scenes, you might link the event with a scene.

Video

SUPLA Events

Installation

  1. Install SUPLA with Docker (yes, you need to have your own SUPLA Cloud).
  2. Downlad the SUPLA Cloud extension from this gist (for example into the home directory, doesn't matter):
    wget https://gist.githubusercontent.com/fracz/9aa9923aec02db8b5ef5baf09113fa63/raw/d7c8dd94a169ce804433c871cd59128ab838800b/SimulateEventsCommand.php
    wget https://gist.githubusercontent.com/fracz/9aa9923aec02db8b5ef5baf09113fa63/raw/d7c8dd94a169ce804433c871cd59128ab838800b/events.yml
    
  3. Copy the command into the SUPLA Cloud container and clear the cache, so the framework notices it:
    docker cp SimulateEventsCommand.php supla-cloud:/var/www/cloud/src/SuplaBundle/Command/SimulateEventsCommand.php
    docker exec supla-cloud rm -fr var/cache/prod
    
  4. Adjust the events.yml configuration to match your needs and copy it into the SUPLA Cloud container:
    docker cp events.yml supla-cloud:/var/www/cloud/src/SuplaBundle/Command/events.yml
    
  5. Add the following entry to your host crontab (you edit crontabs with crontab -e):
    * * * * * /usr/bin/docker exec -u www-data supla-cloud php bin/console supla:unofficial:simulate-events
    

Changing the configuration

Just adjust the events.yml config and copy it again into the container (point 4 in the Installation section).

Upgrading

If there is some fix published, just download the command again and copy it inside the container.

wget -N https://gist.githubusercontent.com/fracz/9aa9923aec02db8b5ef5baf09113fa63/raw/d7c8dd94a169ce804433c871cd59128ab838800b/SimulateEventsCommand.php
docker cp SimulateEventsCommand.php supla-cloud:/var/www/cloud/src/SuplaBundle/Command/SimulateEventsCommand.php

Troubleshooting

If you suppose it's not working, try to disable the crontab and run the command directly:

docker exec -u www-data supla-cloud php bin/console supla:unofficial:simulate-events --dispatch

If you have made some configuration errors, this command should provide you with a helpful output.

docker cp events.yml supla-cloud:/var/www/cloud/src/SuplaBundle/Command/events.yml

Forum

There is a forum thread about this solution here. Gist or youtube comments are also welcome.

# these are examples only, adjust to your needs
events:
- condition: state(324).on # when the channel with ID324 changes its state to ON
time_conditions: "* 19 * * *" # only between 19:00 and 19:59
webhooks: https://supla.fracz.com/api/scenes/public/0da4dbbb-5bfd-4fad-8e3c-aaa # execute this url
- condition: not state(324).on # when the channel with ID324 changes its state to OFF
time_conditions: ["* 19-20 * * *", "30-59 6 * * *"] # only between 19:00-20:59 and 6:30-6:59
webhooks: ["https://supla.fracz.com/api/scenes/public/0da4dbbb-5bfd-4fad-8e3c-aaa", "https://other.url"] # execute these urls one by one
- condition: state(666).hi # when the gate channel with ID 666 is opened
webhooks: https://supla.fracz.com/api/scenes/public/0da4dbbb-5bfd-4fad-8e3c-aaa
- condition: state(123).temperature > 10 and state(123).temperature < 20 # when the temperature of the channel ID 123 is between 10 and 20
webhooks: https://supla.fracz.com/api/scenes/public/0da4dbbb-5bfd-4fad-8e3c-aaa
- condition: state(123).humidity < 10.5 # when the humidity of the channel ID 123 is less than 10.5
webhooks: https://supla.fracz.com/api/scenes/public/0da4dbbb-5bfd-4fad-8e3c-aaa
- condition: state(123).distance > 50 # when the humidity of the channel ID 123 is greater than 50
webhooks: https://supla.fracz.com/api/scenes/public/0da4dbbb-5bfd-4fad-8e3c-aaa
- condition: state(123).temperature > state(124).temperature # when the temperature of the channel ID 123 is greater than temperature of the channel ID 124
webhooks: https://supla.fracz.com/api/scenes/public/0da4dbbb-5bfd-4fad-8e3c-aaa
# crazy one: when the window (ID 200) is opened and the temperature outside (ID 123) is less than temperature inside (ID 123) enlarged by 3
- condition: not state(200).hi and state(123).temperature < state(123).temperature + 3
webhooks: https://supla.fracz.com/api/scenes/public/0da4dbbb-5bfd-4fad-8e3c-aaa
# POST request
- condition: not state(1).hi
webhooks:
url: https://llamalab.com/automate/cloud/message
headers: ['Content-Type: application/json']
payload:
secret: "SECRET"
to: me@gmail.com
device: ~
payload: Gate is opened
<?php
namespace SuplaBundle\Command;
use Assert\Assertion;
use Cron\CronExpression;
use SuplaBundle\Model\ChannelStateGetter\ChannelStateGetter;
use SuplaBundle\Repository\IODeviceChannelRepository;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
use Symfony\Component\Yaml\Parser;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use Twig\TwigFunction;
// v4 - for Cloud v2.3.32+
class SimulateEventsCommand extends ContainerAwareCommand {
const INTERVAL_IN_SECONDS = 2;
const PHP_PATH = '/usr/local/bin/php';
// const PHP_PATH = 'php';
const HOOK_DELAY_MS = 500;
const EVENTS_CONFIG_FILE = __DIR__ . '/events.yml';
const CHANNELS_STATE_FILE = __DIR__ . '/events-state.json';
/** @var ChannelStateGetter */
private $channelStateGetter;
/** @var IODeviceChannelRepository */
private $channelRepository;
/** @var Environment */
private $twig;
public function __construct(ChannelStateGetter $channelStateGetter, IODeviceChannelRepository $channelRepository) {
parent::__construct();
$this->channelStateGetter = $channelStateGetter;
$this->channelRepository = $channelRepository;
$this->twig = new Environment(new FilesystemLoader(__DIR__));
$this->twig->addFunction(new TwigFunction('state', [$this, 'getChannelState']));
}
protected function configure() {
$this
->setName('supla:unofficial:simulate-events')
->setDescription('Simulates events with webhooks.')
->addOption('dispatch', null, InputOption::VALUE_NONE);
}
/** @inheritdoc */
protected function execute(InputInterface $input, OutputInterface $output) {
if ($input->getOption('dispatch')) {
$this->dispatch();
} else {
$this->launch($output);
}
}
private function launch(OutputInterface $output) {
$command = self::PHP_PATH . ' ' . __DIR__ . '/../../../bin/console supla:unofficial:simulate-events --dispatch';
$sleep = 0;
while ($sleep < 60) {
$process = new Process("sleep $sleep && $command");
$process->start();
$sleep += self::INTERVAL_IN_SECONDS;
}
$process->wait();
$output->writeln($process->getOutput());
$output->writeln($process->getErrorOutput());
}
private function dispatch() {
$loader = new Parser();
$events = $loader->parseFile(self::EVENTS_CONFIG_FILE);
if (!file_exists(self::CHANNELS_STATE_FILE)) {
file_put_contents(self::CHANNELS_STATE_FILE, '{}');
}
$states = json_decode(file_get_contents(self::CHANNELS_STATE_FILE), true);
$hooksToExecute = [];
for ($eventIndex = 0; $eventIndex < count($events['events']); $eventIndex++) {
$event = $events['events'][$eventIndex];
$timeSpecs = (array)($event['time_conditions'] ?? ['* * * * *']);
$isConditionMet = false;
foreach ($timeSpecs as $timeSpec) {
$cron = CronExpression::factory($timeSpec);
if ($cron->isDue()) {
$isConditionMet = true;
}
}
if ($isConditionMet) {
$template = $this->twig->createTemplate('{{(' . $event['condition'] . ')?1:0}}');
$isConditionMet = boolval($template->render([]));
if ($isConditionMet && (!isset($states[$eventIndex]) || !$states[$eventIndex])) {
$hooks = $event['webhooks'];
if (is_string($hooks)) {
$hooks = ['url' => $hooks];
}
if (isset($hooks['url'])) {
$hooks = [$hooks];
}
$hooksToExecute[] = $hooks;
}
}
$states[$eventIndex] = $isConditionMet;
}
file_put_contents(self::CHANNELS_STATE_FILE, json_encode($states));
foreach ($hooksToExecute as $hooks) {
foreach ($hooks as $hook) {
$url = $hook['url'];
$requestData = $hook['payload'] ?? '';
$method = $hook['method'] ?? ($requestData ? 'POST' : 'GET');
if (!is_string($requestData)) {
$requestData = json_encode($requestData);
}
$headers = $hook['headers'] ?? [];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
if ($requestData) {
curl_setopt($ch, CURLOPT_POSTFIELDS, $requestData);
}
curl_exec($ch);
curl_close($ch);
usleep(self::HOOK_DELAY_MS * 1000);
}
}
}
public function getChannelState($channelId): array {
// return ['on' => boolval(rand(0, 1) % 2)];
$channel = $this->channelRepository->find($channelId);
Assertion::notNull($channel, "Channel ID$channelId does not exist.");
return $this->channelStateGetter->getState($channel);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment