Skip to content
Merged
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
IOTSERVER_SECRET_KEY='insecure-secret'
IOTSERVER_CORS_ORIGIN_WHITELIST='http://localhost:3000'
IOTSERVER_CORS_ALLOW_ALL_ORIGINS='0'

IOTSERVER_POSTGRES_USER='postgres'
IOTSERVER_POSTGRES_PASSWORD='insecure-password'
IOTSERVER_POSTGRES_DBNAME='iotserver'

IOTSERVER_SONOFF_AUTH_URL='https://eu-api.coolkit.cc:8080/api/user/login'
IOTSERVER_SONOFF_EMAIL='test@example.com'
IOTSERVER_SONOFF_PASSWORD='insecure-password'
Expand All @@ -13,14 +15,18 @@ IOTSERVER_SONOFF_COUNTRY_CODE='+27'
IOTSERVER_SONOFF_APP_ID='sonoff-app-id'
IOTSERVER_SONOFF_APP_SECRET='sonoff-app-secret'
IOTSERVER_SONOFF_DEVICE_URL='https://eu-api.coolkit.cc:8080/api/user/device/status'

IOTSERVER_SOLARMAN_BASE_URL='https://globalapi.solarmanpv.com'
IOTSERVER_SOLARMAN_APP_ID='solarman-app-id'
IOTSERVER_SOLARMAN_APP_SECRET='solarman-app-secret'
IOTSERVER_SOLARMAN_EMAIL='solarman-email@example.com'
IOTSERVER_SOLARMAN_PASSWORD='solarman-password'

IOTSERVER_OPENWEATHER_URL='https://api.openweathermap.org/data/3.0/onecall'
IOTSERVER_OPENWEATHER_APIKEY='openweather-key'

IOTSERVER_GOOGLEMAPS_APIKEY='googlemaps-key'

IOTSERVER_WEBREPL_PORT='8266'
IOTSERVER_WEBREPL_PASSWORD='insecure-password'
IOTSERVER_AUTO_SYNC_DEVICE=1
Expand All @@ -30,3 +36,4 @@ DOCKER_POSTGIS_IMAGE='kartoza/postgis:15'

# GDAL local path
IOTSERVER_GDAL_LIBRARY_PATH='/path/to/your/libgdal.so'
IOTSERVER_GEOS_LIBRARY_PATH='/path/to/your/libgeos_c.so'
1 change: 1 addition & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
./manage.py migrate --noinput
./manage.py collectstatic --noinput
./manage.py mqtt &
./manage.py rules &
gunicorn iotserver.wsgi:application -w 2 -b :8000 --reload
17 changes: 15 additions & 2 deletions iotserver/apps/device/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,19 @@ class DeviceTypeModelAdmin(admin.ModelAdmin):
class DeviceModelAdmin(admin.ModelAdmin):
actions = [toggle_devices_on, toggle_devices_off]
fieldsets = (
(None, {'fields': ('active', 'name', 'description', 'type', 'location')}),
(
None,
{
'fields': (
'active',
'managed_firmware',
'name',
'description',
'type',
'location',
)
},
),
(
'Advanced options',
{
Expand All @@ -53,12 +65,13 @@ class DeviceModelAdmin(admin.ModelAdmin):
'name',
'description',
'active',
'managed_firmware',
'created_at',
'type',
'location',
'ip_address',
)
list_filter = ('active', 'type__name', 'location__name')
list_filter = ('active', 'managed_firmware', 'type__name', 'location__name')
formfield_overrides = {
JSONField: {'widget': widgets.PrettyJSONWidget(attrs={'rows': 20, 'cols': 120})}
}
Expand Down
51 changes: 51 additions & 0 deletions iotserver/apps/device/management/commands/rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import signal
import threading

from django.core.management.base import BaseCommand

from iotserver.apps.device.models import Device
from iotserver.apps.device.utils import rules


class Command(BaseCommand):
help = 'Run the rules engine for non-managed-firmware devices.'

def handle(self, *args, **options):
"""
Spawn one worker thread per active, non-managed-firmware device and run
until interrupted.
"""
stop_event = threading.Event()

devices = Device.objects.filter(managed_firmware=False, active=True)
threads = [
threading.Thread(
target=rules.run_device, args=(device, stop_event), daemon=True
)
for device in devices
]

if not threads:
self.stdout.write(
self.style.WARNING('No active non-managed-firmware devices found.')
)
return

def handle_shutdown(signum, frame):
self.stdout.write(self.style.WARNING('Shutting down rules engine...'))
stop_event.set()

signal.signal(signal.SIGINT, handle_shutdown)
signal.signal(signal.SIGTERM, handle_shutdown)

for thread in threads:
thread.start()

self.stdout.write(
self.style.SUCCESS(f'Rules engine started for {len(threads)} device(s).')
)

for thread in threads:
thread.join()

self.stdout.write(self.style.SUCCESS('Rules engine stopped.'))
17 changes: 17 additions & 0 deletions iotserver/apps/device/migrations/0012_device_managed_firmware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.2.17 on 2026-09-23 17:27

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
('device', '0011_devicepintype_devicetype_identifier_devicepin_type'),
]

operations = [
migrations.AddField(
model_name='device',
name='managed_firmware',
field=models.BooleanField(default=True),
),
]
5 changes: 3 additions & 2 deletions iotserver/apps/device/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class Device(models.Model):
updated_at = models.DateTimeField(auto_now=True)

active = models.BooleanField(default=False)
managed_firmware = models.BooleanField(default=True)

name = models.CharField(max_length=128)
description = models.CharField(max_length=1024)
Expand Down Expand Up @@ -205,7 +206,7 @@ def status(self):
@receiver(pre_save, sender=Device)
def handle_device_default_config(sender, instance, *args, **kwargs):
"""Get the config from the new device and update the config field."""
if settings.AUTO_SYNC_DEVICE:
if settings.AUTO_SYNC_DEVICE and instance.managed_firmware and instance.active:
if instance.config is None:
temp_file_path = f'/tmp/config.{instance.id}.json'
with open(temp_file_path, 'w') as input_file:
Expand All @@ -232,7 +233,7 @@ def handle_device_default_config(sender, instance, *args, **kwargs):
@receiver(post_save, sender=Device)
def handle_device_config_update(sender, instance, *args, **kwargs):
"""Update config on the physical device via webrepl."""
if settings.AUTO_SYNC_DEVICE:
if settings.AUTO_SYNC_DEVICE and instance.managed_firmware:
temp_file_path = f'/tmp/config.{instance.id}.json'
with open(temp_file_path, 'w') as input_file:
input_file.write(json.dumps(instance.full_config, indent=4))
Expand Down
Empty file.
Empty file.
111 changes: 111 additions & 0 deletions iotserver/apps/device/tests/management/commands/test_mqtt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import io
import json

import pytest
from django.core.management.base import OutputWrapper

from iotserver.apps.device.management.commands.mqtt import Command
from iotserver.apps.device.models import DeviceStatus
from iotserver.apps.device.tests import factories as device_factories


@pytest.fixture
def command():
command = Command()
command.stdout = OutputWrapper(io.StringIO())
return command


@pytest.mark.django_db
class TestHandleStatusQueue:
def test_creates_device_status_for_existing_device(self, command, mocker):
device = device_factories.DeviceFactory()
message = mocker.Mock(
topic=f'iot-devices/{device.id}/status',
payload=json.dumps({'sonoff-switch': True}).encode(),
)

command.handle_status_queue(message)

status = DeviceStatus.objects.get(device=device)
assert status.status == {'sonoff-switch': True}

def test_ignores_message_for_unknown_device(self, command, mocker):
message = mocker.Mock(
topic='iot-devices/00000000-0000-0000-0000-000000000000/status',
payload=b'{}',
)

command.handle_status_queue(message)

assert DeviceStatus.objects.count() == 0
assert 'does not exist' in command.stdout._out.getvalue()


class TestHandleLogQueue:
def test_writes_log_message_to_stdout(self, command, mocker):
message = mocker.Mock(
topic='iot-devices/device-1/logs', payload=b'Device disconnected'
)

command.handle_log_queue(message)

output = command.stdout._out.getvalue()
assert 'device-1' in output
assert 'Device disconnected' in output


class TestMqttOnConnect:
def test_subscribes_to_all_device_topics(self, command, mocker):
client = mocker.Mock()

command.mqtt_on_connect(client, None, None, None, None)

client.subscribe.assert_called_once_with('iot-devices/#')


class TestMqttOnMessage:
def test_dispatches_status_messages(self, command, mocker):
mock_handle_status = mocker.patch.object(command, 'handle_status_queue')
message = mocker.Mock(topic='iot-devices/device-1/status')

command.mqtt_on_message(None, None, message)

mock_handle_status.assert_called_once_with(message)

def test_dispatches_log_messages(self, command, mocker):
mock_handle_log = mocker.patch.object(command, 'handle_log_queue')
message = mocker.Mock(topic='iot-devices/device-1/logs')

command.mqtt_on_message(None, None, message)

mock_handle_log.assert_called_once_with(message)

def test_ignores_unrelated_topics(self, command, mocker):
mock_handle_status = mocker.patch.object(command, 'handle_status_queue')
mock_handle_log = mocker.patch.object(command, 'handle_log_queue')
message = mocker.Mock(topic='iot-devices/device-1/toggle')

command.mqtt_on_message(None, None, message)

mock_handle_status.assert_not_called()
mock_handle_log.assert_not_called()


class TestHandle:
def test_connects_and_starts_loop_forever(self, command, mocker):
mock_client_cls = mocker.patch(
'iotserver.apps.device.management.commands.mqtt.mqtt.Client'
)
mocker.patch(
'iotserver.apps.device.management.commands.mqtt.settings.MQTT',
{'host': 'broker.local', 'port': 1883},
)

command.handle()

client = mock_client_cls.return_value
assert client.on_connect == command.mqtt_on_connect
assert client.on_message == command.mqtt_on_message
client.connect.assert_called_once_with(host='broker.local', port=1883)
client.loop_forever.assert_called_once()
47 changes: 47 additions & 0 deletions iotserver/apps/device/tests/management/commands/test_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import pytest
from django.core.management import call_command

from iotserver.apps.device.tests import factories as device_factories


@pytest.mark.django_db
class TestRulesCommand:
def test_spawns_one_thread_per_active_non_managed_device(self, mocker):
matching_device = device_factories.DeviceFactory(
managed_firmware=False,
active=True,
ip_address='192.168.0.1',
mac_address='0E:00:20:01:71:AE',
)
device_factories.DeviceFactory(
managed_firmware=True,
active=True,
ip_address='192.168.0.2',
mac_address='0E:00:20:01:71:AF',
)
device_factories.DeviceFactory(
managed_firmware=False,
active=False,
ip_address='192.168.0.3',
mac_address='0E:00:20:01:71:B0',
)

mock_thread_cls = mocker.patch('threading.Thread')

call_command('rules')

assert mock_thread_cls.call_count == 1
_, kwargs = mock_thread_cls.call_args
assert kwargs['args'][0] == matching_device
assert kwargs['daemon'] is True
mock_thread_cls.return_value.start.assert_called_once()
mock_thread_cls.return_value.join.assert_called_once()

def test_exits_without_spawning_when_no_devices_match(self, mocker):
device_factories.DeviceFactory(managed_firmware=True, active=True)

mock_thread_cls = mocker.patch('threading.Thread')

call_command('rules')

mock_thread_cls.assert_not_called()
53 changes: 53 additions & 0 deletions iotserver/apps/device/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import json

import pytest

from iotserver.apps.device import models
from iotserver.apps.device.tests import factories as device_factories


Expand Down Expand Up @@ -94,6 +97,56 @@ def test_mqtt_toggle(self, mocker):
self.device.mqtt_toggle('on')
mock_mqtt_toggle.assert_called_once_with(self.device.id, '1')

def test_handle_device_default_config(self, settings, mocker):
settings.AUTO_SYNC_DEVICE = True
self.device.managed_firmware = True
self.device.active = True
self.device.config = None

mock_web_socket = mocker.Mock()
mock_get_websocket = mocker.patch(
'iotserver.apps.device.models.webrepl.get_websocket',
return_value=(mocker.Mock(), mock_web_socket),
)

def fake_get_file(web_socket, path, remote_path):
with open(path, 'w') as input_file:
input_file.write(json.dumps({'main': {}, 'pins': []}))

mocker.patch(
'iotserver.apps.device.models.webrepl.get_file', side_effect=fake_get_file
)

models.handle_device_default_config(sender=models.Device, instance=self.device)

mock_get_websocket.assert_called_once_with(
self.device.ip_address, settings.WEBREPL_PORT, settings.WEBREPL_PASSWORD
)
assert self.device.config == {'main': {'identifier': str(self.device.id)}}

def test_handle_device_config_update(self, settings, mocker):
settings.AUTO_SYNC_DEVICE = True
self.device.managed_firmware = True
self.device.config = {'main': {}}

mock_web_socket = mocker.Mock()
mock_get_websocket = mocker.patch(
'iotserver.apps.device.models.webrepl.get_websocket',
return_value=(mocker.Mock(), mock_web_socket),
)
mock_put_file = mocker.patch('iotserver.apps.device.models.webrepl.put_file')

models.handle_device_config_update(sender=models.Device, instance=self.device)

mock_get_websocket.assert_called_once_with(
self.device.ip_address, settings.WEBREPL_PORT, settings.WEBREPL_PASSWORD
)
mock_put_file.assert_called_once_with(
mock_web_socket,
f'/tmp/config.{self.device.id}.json',
'config/config.json',
)


@pytest.mark.django_db
class TestDevicePinModel(object):
Expand Down
Loading
Loading