├── .gitignore ├── README.md ├── app.py ├── camera_dependencies.txt ├── config.ini.sample_DE ├── doc ├── changelogs │ ├── changelog_v1.5.0_de.md │ ├── changelog_v1.5.0_en.md │ ├── changelog_v1.5.1_de.md │ ├── changelog_v1.5.1_en.md │ ├── changelog_v1.5.2_de.md │ ├── changelog_v1.5.2_en.md │ ├── changelog_v1.5.3_de.md │ ├── changelog_v1.5.3_en.md │ ├── changelog_v2.0.0_de.md │ ├── changelog_v2.0.0_en.md │ ├── changelog_v2.0.1_de.md │ ├── changelog_v2.0.1_en.md │ ├── changelog_v2.1.0_de.md │ ├── changelog_v2.1.0_en.md │ ├── changelog_v2.1.1_de.md │ ├── changelog_v2.1.1_en.md │ ├── changelog_v2.2.0_de.md │ ├── changelog_v2.2.0_en.md │ └── template.md ├── free-disk-space-example.jpg ├── kittyflap-hostname.png └── swap-resize.jpg ├── locales ├── de │ └── LC_MESSAGES │ │ ├── .gitkeep │ │ ├── messages.mo │ │ └── messages.po ├── en │ └── LC_MESSAGES │ │ ├── .gitkeep │ │ ├── messages.mo │ │ └── messages.po ├── messages.pot └── readme.md ├── requirements.txt ├── setup ├── kittyhack-setup.sh ├── kittyhack.service └── labelstudio.service ├── src ├── backend.py ├── baseconfig.py ├── camera.py ├── database.py ├── helper.py ├── js │ └── app.js ├── magnets_rfid.py ├── model.py ├── pir.py ├── server.py ├── shiny_wrappers.py ├── system.py └── ui.py ├── styles.css ├── tflite ├── cv-lite-model.tflite ├── labels.txt ├── original_kittyflap_model_v1 │ ├── cv-lite-model.tflite │ └── labels.txt └── original_kittyflap_model_v2 │ ├── cv-lite-model.tflite │ └── labels.txt └── www ├── favicon-120x120.png ├── favicon-128x128.png ├── favicon-144x144.png ├── favicon-152x152.png ├── favicon-16x16.png ├── favicon-180x180.png ├── favicon-192x192.png ├── favicon-256x256.png ├── favicon-32x32.png ├── favicon-384x384.png ├── favicon-512x512.png ├── favicon-64x64.png ├── favicon-72x72.png ├── favicon-96x96.png ├── favicon.ico ├── icons ├── mouse.svg ├── prey-frame-off.svg └── prey-frame-on.svg ├── manifest.json ├── offline.html └── pwa-service-worker.js /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | kittyhack.log* 3 | *.db 4 | .venv/ 5 | *.po~ 6 | config.ini 7 | sync-to-kittyflap.sh 8 | *.code-workspace 9 | notifications.json 10 | ignore_* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kittyhack 2 | 3 | ### [German version below / Deutsche Version weiter unten!](#deutsch) 4 | 5 | --- 6 | 7 | Kittyhack is an open-source project that enables offline use of the Kittyflap cat door—completely without internet access. It was created after the manufacturer of Kittyflap filed for bankruptcy, rendering the associated app non-functional. 8 | 9 | ⚠️ **Important Notes** 10 | I have no connection to the manufacturer of Kittyflap. This project was developed on my own initiative to continue using my Kittyflap. 11 | 12 | If you find any bugs or have suggestions for improvement, please report them on the GitHub issue tracker. 13 | 14 | --- 15 | 16 | ## Features 17 | 18 | Until version `v1.1.x`, Kittyhack was merely a frontend for visualizing camera images and changing some settings. 19 | From version `v1.2.0`, Kittyhack replaces the complete original Kittyflap software with extended functionality. 20 | 21 | Current features: 22 | - **Toggle prey detection** 23 | - **Configure thresholds for mouse detection** 24 | - **Switch entry direction** between "All cats", "All chipped cats", "my cats only" or "no cats" 25 | - **Block exit direction** 26 | - **Display captured images** (filterable by date, prey, and cat detection) 27 | - **Show overlay with detected objects** 28 | - **Live camera feed** 29 | - **Manage cats and add new cats** 30 | - **Show incoming/outgoing Events** 31 | - **AI Training** Create a custom object detection model for your cat and your environment by using your own images 32 | 33 | --- 34 | 35 | ## Planned Features 36 | 37 | - **Home Assistant integration via MQTT** 38 | Planned support for Home Assistant through MQTT, allowing automation and monitoring of the Kittyflap from your smart home system. 39 | 40 | --- 41 | 42 | ## Installation 43 | 44 | ### Prerequisites 45 | - Access to the Kittyflap via SSH 46 | You can usually find the Kittyflap's IP address in your router. 47 | - The hostname begins with `kittyflap-` 48 | - The MAC address should start with `d8:3a:dd`. 49 | ![kittyflap router configuration](doc/kittyflap-hostname.png) 50 | 51 | ### If your Kittyflap hasn't been set up yet 52 | If you never configured your Kittyflap with the official app, it is preset to a default WiFi network. 53 | To establish a connection, you need to temporarily adjust your router's WiFi settings. 54 | 55 | Use one of these combinations: 56 | - SSID: `Graewer factory`, Password: `Graewer2023` 57 | - SSID: `Graewer Factory`, Password: `Graewer2023` 58 | - SSID: `GEG-Gast`, Password: `GEG-1234` 59 | 60 | After changing the router SSID: 61 | 1. Restart the Kittyflap 62 | 2. Wait until it appears as a client in your router 63 | 3. Connect [via SSH](#ssh_access_en) (User: `pi`, Password: `kittyflap`) 64 | 65 | Now proceed with the installation. You can add your own WLAN configuration later on in the Kittyhack Web interface. 66 | 67 | ### Instructions 68 | The setup is quite simple: 69 | 70 | 71 | 1. **Establish SSH Access** 72 | Open a terminal (on Windows, for example, with the key combination `[WIN]`+`[R]`, then enter `cmd` and execute) and connect to your Kittyflap via SSH with the following command: 73 | ```bash 74 | ssh pi@ 75 | ``` 76 | Username: `pi` 77 | Default password: `kittyflap` 78 | > **NOTE:** You have to enter the password "blindly", as no characters will be displayed while typing. 79 | 80 | 2. **Check available disk space** 81 | If your cat flap was still active for an extended period after the Kittyflap servers were shut down, the file system might be full. 82 | In this case, you need to free up space before installing Kittyhack. 83 | 84 | Check available disk space: 85 | ```bash 86 | df -h 87 | ``` 88 | For `/dev/mmcblk0p2`, there should be **at least** 1 GB of free space available: 89 | ![free disk space](doc/free-disk-space-example.jpg) 90 | 91 | #### If less storage space is available, follow these steps - otherwise, proceed with the [Setup Script](#setup_en): 92 | 93 | 1. Stop Kittyflap processes: 94 | ```bash 95 | sudo systemctl stop kwork 96 | sudo systemctl stop manager 97 | ``` 98 | 99 | 2. Release magnetic switches: 100 | **ATTENTION:** If one of the magnetic switches is still active at this point (i.e., the flap is unlocked), they will not be automatically deactivated until the end of the installation. 101 | Please make sure to deactivate them now with these commands to avoid overloading the electromagnets: 102 | ```bash 103 | # Export GPIOs 104 | echo 525 > /sys/class/gpio/export 2>/dev/null 105 | echo 524 > /sys/class/gpio/export 2>/dev/null 106 | 107 | # Configure GPIO directions 108 | echo out > /sys/devices/platform/soc/fe200000.gpio/gpiochip0/gpio/gpio525/direction 109 | echo out > /sys/devices/platform/soc/fe200000.gpio/gpiochip0/gpio/gpio524/direction 110 | 111 | # Set default output values for GPIOs 112 | echo 0 > /sys/devices/platform/soc/fe200000.gpio/gpiochip0/gpio/gpio525/value 113 | sleep 1 114 | echo 0 > /sys/devices/platform/soc/fe200000.gpio/gpiochip0/gpio/gpio524/value 115 | ``` 116 | 117 | 3. Reduce the size of the swap file (by default, 6GB are reserved for this): 118 | ```bash 119 | # Turn off and remove the current swapfile 120 | sudo swapoff /swapfile 121 | sudo rm /swapfile 122 | 123 | # Create a new 2GB swapfile 124 | sudo fallocate -l 2G /swapfile 125 | sudo chmod 600 /swapfile 126 | sudo mkswap /swapfile 127 | sudo swapon /swapfile 128 | ``` 129 | 130 | As confirmation, you will receive the size of the new swap file. The result should look something like this: 131 | ![free disk space](doc/swap-resize.jpg) 132 | 133 | After this, `/dev/mmcblk0p2` should have significantly more free space available. 134 | 135 | 136 | 137 | 3. **Run the setup script on the Kittyflap** 138 | > **IMPORTANT:** Before starting the installation, please ensure that the WiFi connection of the cat flap is stable. During installation, several hundred MB of data will be downloaded! 139 | > Since the antenna is mounted on the outside of the flap, the signal strength can be significantly weakened by e.g. a metal door. 140 | 141 | You can check the strength of the WiFi signal with this command: 142 | ```bash 143 | iwconfig wlan0 144 | ``` 145 | Run the installation: 146 | ```bash 147 | sudo curl -sSL https://raw.githubusercontent.com/floppyFK/kittyhack/main/setup/kittyhack-setup.sh -o /tmp/kittyhack-setup.sh && sudo chmod +x /tmp/kittyhack-setup.sh && sudo /tmp/kittyhack-setup.sh && sudo rm /tmp/kittyhack-setup.sh 148 | ``` 149 | You can choose between the following options: 150 | - **Initial installation**: Performs the complete setup, including stopping and removing unwanted services on the Kittyflap (only to be executed once) 151 | - **Reinstall camera drivers**: This will reinstall the necessary device drivers in the system. Only to be executed if there are problems with the camera image. This installation is also possible directly through the web interface. 152 | - **Update to the latest version**: If a first-time installation of Kittyhack has already been performed, this option is sufficient to update to the latest version. The existing system configuration will not be changed. 153 | 154 | That's it! 155 | 156 | ### Language Settings 157 | By default, the language is set to English. You can adjust the configuration in the web interface or pre-load the German configuration file: 158 | ```bash 159 | sudo cp /root/kittyhack/config.ini.sample_DE /root/kittyhack/config.ini 160 | ``` 161 | 162 | ### Access the Kittyhack Web Interface 163 | Open the Kittyflap's IP address in your browser: 164 | `http://` 165 | 166 | >#### Note 167 | >⚠️ Since the connection is unencrypted, the browser will display a warning. This connection is generally safe within the local network, as long as you don't enable remote access 168 | to the Kittyflap via your router. For a secure connection, additional measures like setting up a reverse proxy can be taken. 169 | 170 | >⚠️ To ensure Kittyhack is always reachable at the same IP address, it is recommended to assign a static IP address in your router. 171 | 172 | ### Updates 173 | Updates for Kittyhack are available directly in the WebGUI in the 'Info' section. 174 | Alternatively, you can also run the [setup script](#setup_en) again on the Kittyflap to perform an update. 175 | 176 | 177 | ## FAQ 178 | 179 | ### My Kittyflap disappears from my WLAN after a few hours 180 | The WLAN signal is probably too weak because the WLAN antenna is mounted on the outside of the Kittyflap and has to pass therefore an additional wall or door to reach your router. 181 | Make sure the distance to the router is not too great. If the WLAN signal is too weak, the Kittyflap will eventually disconnect and will only reconnect after being restarted by 182 | unplugging and plugging it back in (I am still investigating why this happens - I am trying to find a solution!). 183 | 184 | ### Why does the website background turn gray and the content disappear when I try to switch sections? 185 | This issue is caused by the power-saving features of smartphones and tablets: When your browser on your smartphone loses focus (for example, when switching to the home screen), the connection to the Kittyhack page is interrupted after a few seconds. 186 | By now (since v1.5), the page should automatically reload itself after such a connection loss. If this does not happen, please reload the page manually (e.g., using the refresh gesture). 187 | 188 | ### I have successfully installed Kittyhack. Shouldn't the night light be activated when it gets too dark? 189 | The switching to night mode (infrared filter) is handled automatically by the built-in camera module. If you cover the area of the camera module completely with your hand during sufficient daylight, you should hear a faint clicking sound. If you do not hear this click, the switching is probably defective. 190 | There have already been some cases where the switching was defective. In this case, the module (Akozon 5MP OV5647) unfortunately has to be replaced. 191 | You can find more details in this post: https://github.com/floppyFK/kittyhack/issues/81 192 | 193 | ### My cat flap recognizes everything as prey - except the actual prey. The detected zones are somewhere random in the image. 194 | You are probably still using an original object detection model from Kittyflap. These original models are not reliable! 195 | It is highly recommended to train your own model. You can find instructions in the [Wiki](https://github.com/floppyFK/kittyhack/wiki/%5BEN%5D-Kittyhack-v2.0-%E2%80%90-Train-own-AI%E2%80%90Models). 196 | If you are already using your own model, continue to train and refine it. Also, make sure that only high-quality and meaningful images are included in your datasets. 197 | 198 | ### I constantly get motion detected outside - what can I do? 199 | Starting with version 2.1.0, you can use the camera image for motion detection as an alternative to the external PIR sensor. 200 | This significantly reduces false triggers, for example by trees or people in the image. However, a well-trained, custom detection model is required for this feature. 201 | You can find the option under **Configuration** → **Use camera for motion detection**. 202 | 203 | 204 | --- 205 | 206 | 207 | # DEUTSCH 208 | 209 | Kittyhack ist ein Open-Source-Projekt, das die Offline-Nutzung der Kittyflap-Katzenklappe ermöglicht – ganz ohne Internetzugang. Es wurde ins Leben gerufen, nachdem der Anbieter der Kittyflap Insolvenz angemeldet hat und die zugehörige App nicht mehr funktionierte. 210 | 211 | ⚠️ **Wichtige Hinweise** 212 | Ich stehe in keinerlei Verbindung mit dem Hersteller der Kittyflap. Dieses Projekt wurde aus eigenem Antrieb erstellt, um meine eigene Katzenklappe weiterhin nutzen zu können. 213 | 214 | Wenn du Bugs findest oder Verbesserungsvorschläge hast, melde sie bitte im Issue Tracker dieses GitHub Projekts. 215 | 216 | --- 217 | 218 | ## Funktionsumfang 219 | 220 | Bis Version `v1.1.x` war Kittyhack lediglich ein Frontend zur Visualisierung der Kamerabilder und zum Ändern einiger Einstellungen. 221 | Ab Version `v1.2.0` ersetzt Kittyhack die komplette Originalsoftware der Kittyflap mit einem erweiterten Funktionsumfang. 222 | 223 | Aktuelle Features: 224 | - **Beuteerkennung ein-/ausschalten** 225 | - **Schwellwerte für Mauserkennung konfigurieren** 226 | - **Eingangsrichtung umschalten** zwischen "Alle Katzen", "Alle gechippten Katzen", "nur meine Katzen" oder "keine Katzen" 227 | - **Ausgangsrichtung blockieren** 228 | - **Aufgenommene Bilder anzeigen** (filterbar nach Datum, Beute und Katzenerkennung) 229 | - **Overlay mit erkannten Objekten anzeigen** 230 | - **Live-Bild der Kamera** 231 | - **Katzen verwalten und neue Katzen hinzufügen** 232 | - **Ereignisse von ankommenden/rausgehenden Katzen anzeigen** 233 | - **"KI" Modell Training** Erstelle ein individuelles Objekterkennungsmodell für deine Katze und deine Umgebung anhand der eigenen Bilder 234 | 235 | --- 236 | 237 | ## Geplante Features 238 | 239 | - **Home Assistant Unterstützung via MQTT** 240 | Geplante Unterstützung für Home Assistant über MQTT, um die Kittyflap in dein Smart Home zu integrieren und zu automatisieren. 241 | 242 | --- 243 | 244 | ## Installation 245 | 246 | ### Voraussetzungen 247 | - Zugriff auf die Kittyflap per SSH 248 | Die IP-Adresse der Kittyflap kann üblicherweise im Router ausgelesen werden. 249 | - Der Hostname beginnt mit `kittyflap-` 250 | - Die MAC-Adresse sollte mit `d8:3a:dd` beginnen. 251 | ![kittyflap router configuration](doc/kittyflap-hostname.png) 252 | 253 | ### Wenn deine Kittyflap noch nicht eingerichtet wurde 254 | Falls du deine Kittyflap nie mit der offiziellen App konfiguriert hast, ist sie auf ein Standard-WLAN voreingestellt. 255 | Um eine Verbindung herzustellen, musst du vorübergehend die WLAN-Einstellungen deines Routers anpassen. 256 | 257 | Verwende eine dieser Kombinationen: 258 | - SSID: `Graewer factory`, Passwort: `Graewer2023` 259 | - SSID: `Graewer Factory`, Passwort: `Graewer2023` 260 | - SSID: `GEG-Gast`, Passwort: `GEG-1234` 261 | 262 | Nach Änderung der Router-SSID: 263 | 1. Starte die Kittyflap neu 264 | 2. Warte bis sie im Router als Client erscheint 265 | 3. Verbinde dich [per SSH](#ssh_access_de) (Benutzer: `pi`, Passwort: `kittyflap`) 266 | 267 | Fahre jetzt mit der Installation fort. Du kannst dein eigenes WLAN später im Web Interface von Kittyhack konfigurieren. 268 | 269 | ### Anleitung 270 | Die Installation ist kinderleicht: 271 | 272 | 273 | 1. **SSH-Zugriff herstellen** 274 | Öffne ein Terminal (unter Windows z.B. mit der Tastenkombination `[WIN]`+`[R]`, dann `cmd` eingeben und ausführen) und verbinde dich mit dem folgenden Kommando per SSH zu deiner Kittyflap: 275 | ```bash 276 | ssh pi@ 277 | ``` 278 | Benutzername: `pi` 279 | Standardpasswort: `kittyflap` 280 | > **HINWEIS:** Du musst das Passwort "blind" eingeben, da beim Tippen keine Zeichen angezeigt werden. 281 | 282 | 2. **Freien Speicherplatz überprüfen** 283 | Falls deine Katzenklappe nach der Abschaltung der Kittyflap-Server noch längere Zeit aktiv war, kann es sein, dass das Dateisystem vollgeschrieben ist. 284 | In diesem Fall musst du vor der Installation von Kittyhack erst Platz schaffen. 285 | 286 | Vorhandenen Speicherplatz überprüfen: 287 | ```bash 288 | df -h 289 | ``` 290 | Für `/dev/mmcblk0p2` sollte **mindestens** 1 GB freier Speicherplatz zur Verfügung stehen: 291 | ![free disk space](doc/free-disk-space-example.jpg) 292 | 293 | #### Falls weniger Speicherplatz verfügbar ist, führe folgende Schritte aus - ansonsten fahre fort mit dem [Setup Script](#setup_de): 294 | 295 | 1. Kittyflap-Prozesse stoppen: 296 | ```bash 297 | sudo systemctl stop kwork 298 | sudo systemctl stop manager 299 | ``` 300 | 301 | 2. Magnetschalter deaktivieren: 302 | **ACHTUNG:** Falls zu diesem Zeitpunkt noch einer der Magnetschalter aktiv ist (also die Klappe entriegelt ist), werden diese bis zum Ende der Installation nicht mehr automatisch deaktiviert. 303 | Bitte deaktiviere sie unbedingt jetzt mit diesen Kommandos, um die Elektromagneten nicht zu überlasten: 304 | ```bash 305 | # Export GPIOs 306 | echo 525 > /sys/class/gpio/export 2>/dev/null 307 | echo 524 > /sys/class/gpio/export 2>/dev/null 308 | 309 | # Configure GPIO directions 310 | echo out > /sys/devices/platform/soc/fe200000.gpio/gpiochip0/gpio/gpio525/direction 311 | echo out > /sys/devices/platform/soc/fe200000.gpio/gpiochip0/gpio/gpio524/direction 312 | 313 | # Set default output values for GPIOs 314 | echo 0 > /sys/devices/platform/soc/fe200000.gpio/gpiochip0/gpio/gpio525/value 315 | sleep 1 316 | echo 0 > /sys/devices/platform/soc/fe200000.gpio/gpiochip0/gpio/gpio524/value 317 | ``` 318 | 319 | 320 | 3. Größe der Swap-Datei reduzieren (standardmäßig sind hierfür 6GB reserviert): 321 | ```bash 322 | # Turn off and remove the current swapfile 323 | sudo swapoff /swapfile 324 | sudo rm /swapfile 325 | 326 | # Create a new 2GB swapfile 327 | sudo fallocate -l 2G /swapfile 328 | sudo chmod 600 /swapfile 329 | sudo mkswap /swapfile 330 | sudo swapon /swapfile 331 | ``` 332 | 333 | Als Bestätigung bekommst du die Größe des neuen Swap-Datei zurückgemeldet. Das Ergebnis sollte etwa so aussehen: 334 | ![free disk space](doc/swap-resize.jpg) 335 | 336 | Danach sollte für `/dev/mmcblk0p2` deutlich mehr freier Speicherplatz verfügbar sein. 337 | 338 | 339 | 340 | 3. **Das Setup Script auf der Kittyflap ausführen** 341 | > **WICHTIG:** Bitte stelle vor dem Start der Installation sicher, dass die WLAN-Verbindung der Katzenklappe stabil ist. Während der Installation werden mehrere hundert MB an Daten heruntergeladen! 342 | > Da die Antenne auf der Außenseite der Klappe angebracht ist, kann die Signalstärke durch z.B. eine Metalltür stark abgeschwächt werden. 343 | 344 | Mit diesem Befehl kannst du die Stärke des WLAN-Signals überprüfen: 345 | ```bash 346 | iwconfig wlan0 347 | ``` 348 | Installation ausführen: 349 | ```bash 350 | sudo curl -sSL https://raw.githubusercontent.com/floppyFK/kittyhack/main/setup/kittyhack-setup.sh -o /tmp/kittyhack-setup.sh && sudo chmod +x /tmp/kittyhack-setup.sh && sudo /tmp/kittyhack-setup.sh de && sudo rm /tmp/kittyhack-setup.sh 351 | ``` 352 | Du hast die Auswahl zwischen folgenden Optionen: 353 | - **Erstmalige Installation**: Führt das komplette Setup aus, inklusive stoppen und entfernen von ungewollten Services auf der Kittyflap (nur erstmalig auszuführen) 354 | - **Kameratreiber erneut installieren**: Damit werden die erforderlichen Gerätetreiber im System erneut installiert. Nur auszuführen, wenn es Probleme mit dem Kamerabild geben sollte. Diese Installation ist auch direkt über das Web-Interface möglich. 355 | - **Update auf die neueste Version**: Wenn bereits eine erstmalige Installation von Kittyhack ausgeführt wurde, reicht diese Option, um auf den aktuellsten Stand zu aktualisieren. An der bestehenden Systemkonfiguration wird nichts geändert. 356 | 357 | Das war's! 358 | 359 | ### Spracheinstellungen 360 | Standardmäßig ist die Sprache auf Englisch eingestellt. Du kannst die Konfiguration entweder im Webinterface anpassen oder die deutsche Konfigurationsdatei vorab laden: 361 | ```bash 362 | sudo cp /root/kittyhack/config.ini.sample_DE /root/kittyhack/config.ini 363 | ``` 364 | 365 | ### Zugriff auf das Kittyhack Webinterface 366 | Rufe die IP-Adresse der Kittyflap in deinem Browser auf: 367 | `http://` 368 | 369 | >#### Hinweis 370 | >⚠️ Da die Verbindung nicht verschlüsselt ist, wird der Browser eine Warnung anzeigen. Diese Verbindung ist innerhalb des lokalen Netzwerks in der Regel sicher, solange du keinen Fernzugriff 371 | auf die Kittyflap über deinen Router freigibst. Für eine sichere Verbindung können zusätzliche Maßnahmen wie ein Reverse-Proxy eingerichtet werden. 372 | 373 | >⚠️ Damit Kittyhack immer unter der selben IP Adresse erreichbar ist, empfiehlt es sich, im Router eine statische IP Adresse zu vergeben. 374 | 375 | ### Updates 376 | Updates von Kittyhack sind direkt in der WebGUI in der Sektion 'Info' möglich. 377 | Alternativ zu den Updates über die WebGUI kann auch das [Setup Script](#setup_de) erneut ausgeführt werden. Auch dort ist ein Update möglich. 378 | 379 | 380 | ## FAQ 381 | 382 | ### Meine Kittyflap verschwindet nach einigen Stunden immer wieder aus meinem WLAN 383 | Wahrscheinlich ist das WLAN Signal zu schwach, da die WLAN-Antenne auf der Außenseite der Kittyflap angebracht ist und bis zu deinem Router somit eine zusätzliche Wand bzw. Türe durchdringen muss. 384 | Achte darauf, dass die Entfernung zum Router nicht zu groß ist. Wenn das WLAN-Signal zu schwach ist, meldet sich die Kittyflap irgendwann ab und wählt sich erst wieder ein, 385 | wenn sie durch Aus- und Wiedereinstecken neu gestartet wurde (warum das so ist untersuche ich noch - ich versuche, eine Lösung dafür zu finden!) 386 | 387 | ### Warum ist der Hintergrund der Website ausgegraut und der Inhalt verschwindet, wenn ich versuche, die Sektion wechsle? 388 | Dieses Problem hängt mit den Energiesparfunktionen von Smartphones und Tablets zusammen: Wenn dein Browser auf dem Smartphone den Fokus verliert (z. B. beim Wechsel auf den Homescreen), wird nach wenigen Sekunden die Verbindung zur Kittyhack-Seite getrennt. 389 | Inzwischen (seit v1.5) sollte die Seite sich bei einem solchen Verbindungsabbruch automatisch neu laden. Falls das nicht geschieht, lade die Seite bitte einmal manuell neu (z. B. mit der Aktualisieren-Geste). 390 | 391 | ### Ich habe Kittyhack erfolgreich installiert. Sollte nun nicht das Nachtlicht aktiviert werden, wenn es zu dunkel ist? 392 | Die Umschaltung zum Nachtlicht (Infrarot-Filter) erfolgt autark über das verbaute Kameramodul. Wenn der Bereich des Kameramoduls – bei ausreichendem Tageslicht – vollflächig mit der Hand abgedeckt wird, sollte ein leises Klicken zu hören sein. Ist dieses Klicken nicht zu hören, ist die Umschaltung vermutlich defekt. 393 | Es gab bereits einige Fälle, bei denen die Umschaltung defekt war. In diesem Fall muss das Modul (Akozon 5MP OV5647) leider ausgetauscht werden. 394 | Weitere Details findest du in diesem Beitrag: https://github.com/floppyFK/kittyhack/issues/81 395 | 396 | ### Bei meiner Katzenklappe wird alles mögliche als Beute erkannt, nur nicht die Beute selbst. Die Zonen für die erkannten Bereiche liegen irgendwo im Bild. 397 | Du verwendest vermutlich noch ein originales Modell der Kittyflap für die Objekterkennung. Diese Modelle sind nicht zuverlässig! 398 | Trainiere unbedingt ein eigenes Modell. Wie das funktioniert, kannst du im [Wiki](https://github.com/floppyFK/kittyhack/wiki/%5BDE%5D-Kittyhack-v2.0-%E2%80%90-Eigene-KI%E2%80%90Modelle-trainieren) nachlesen. 399 | Falls du bereits ein eigenes Modell verwendest, solltest du es weiter trainieren und verfeinern. Achte außerdem darauf, dass nur wirklich gute und aussagekräftige Bilder in deinen Datensätzen enthalten sind. 400 | 401 | ### Bei mir wird ständig Bewegung außen erkannt, was kann ich tun? 402 | Ab Version 2.1.0 kannst du alternativ zum äußeren PIR-Sensor auch das Kamerabild zur Bewegungserkennung nutzen. 403 | Dies reduziert Fehlauslösungen, zum Beispiel durch Bäume oder Menschen im Bild, deutlich. Voraussetzung ist jedoch ein gut trainiertes, eigenes Erkennungsmodell. 404 | Die Option findest du unter **Konfiguration** → **Kamera für die Bewegungserkennung verwenden**. -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from shiny import App 2 | from src.ui import app_ui 3 | from src.server import server 4 | import os 5 | 6 | path_www = os.path.join(os.path.dirname(__file__), "www") 7 | 8 | app = App(app_ui, server, static_assets={"/": path_www}) -------------------------------------------------------------------------------- /camera_dependencies.txt: -------------------------------------------------------------------------------- 1 | libcamera-ipa_0.2.0+rpt20240418-1_arm64.deb 2 | libcamera0.2_0.2.0+rpt20240418-1_arm64.deb 3 | libcamera0.3_0.3.2+rpt20241119-1_arm64.deb 4 | gstreamer1.0-libcamera_0.3.2+rpt20241119-1_arm64.deb 5 | libpisp1_1.0.7-1_arm64.deb 6 | libpisp-common_1.0.7-1_all.deb 7 | rpicam-apps_1.4.4-1.deb -------------------------------------------------------------------------------- /config.ini.sample_DE: -------------------------------------------------------------------------------- 1 | [Settings] 2 | # See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid timezone strings 3 | timezone = Europe/Berlin 4 | language = de 5 | date_format = dd.mm.yyyy 6 | database_path = ../kittyflap.db 7 | mouse_threshold = 75.0 8 | 9 | # How many images shall be displayed at the same time 10 | # higher numbers may have an significant impact on the performance 11 | elements_per_page = 20 12 | 13 | # If simulate_kittyflap is set to 'True', the actions which require the Kittyflap 14 | # hardware, will be simulated 15 | simulate_kittyflap = False 16 | 17 | # Possible values for loglevel: 18 | # DEBUG, INFO, WARN, ERROR, CRITICAL 19 | loglevel = INFO 20 | -------------------------------------------------------------------------------- /doc/changelogs/changelog_v1.5.0_de.md: -------------------------------------------------------------------------------- 1 | # v1.5.0 2 | 3 | ## Highlights 4 | Mit Version 1.5.0 beginnen die ersten Vorbereitungen, damit du die "KI" an deine Katze und deine Umgebung anpassen kannst! 5 | Zunächst müssen viele Daten gesammelt werden, um die "KI" später trainieren zu können. 6 | 7 | Was bedeutet das konkret? 8 | Für eine zuverlässige Objekterkennung werden später **mindestens** 100 Bilder für die Kategorie *Keine Maus* und ebenfalls mindestens 100 Bilder für die Kategorie *Maus* benötigt. (Vögel und andere Tiere werden dabei einfach als Maus klassifiziert 😉). 9 | In der Event-Ansicht gibt es nun einen Download-Button, über den du alle Bilder eines Ereignisses als *.zip*-Datei herunterladen kannst. 10 | Du solltest möglichst viele verschiedene Bilder sammeln. Je größer später die Varianz ist, desto besser – also sammle Fotos bei Tag, bei Nacht, bei Sonnenschein mit starken Schattenwürfen usw. 11 | 12 | > **Hinweis:** Beim Herunterladen der Bilder als *.zip*-Datei kann dein Browser (z. B. Google Chrome) eine Warnung anzeigen, dass die Verbindung nicht sicher ist und der Download blockiert wurde. Diese Meldung ist unbedenklich, solange du nur aus deinem heimischen WLAN auf die Kittyflap zugreifst. Falls nötig, musst du bestätigen, dass du die Datei trotzdem behalten möchtest. 13 | > Eine sichere Verbindung über HTTPS schafft hier Abhilfe. Anleitungen dazu findest du online – ein möglicher Ansatz ist ein *Reverse Proxy*, wie beispielsweise der [NGINX Proxy Manager](https://nginxproxymanager.com/). 14 | 15 | Zusätzlich wurde eine neue Modellvariante für die Objekterkennung als Standard ausgewählt. Damit sollte die Fehldetektion von vermeintlichen Mäusen an Terrassenmöbeln oder ähnlichen Objekten reduziert werden. Ob dieses Modell in allen Situationen besser funktioniert, lässt sich nicht pauschal sagen – daher kannst du zwischen der neuen und der alten Variante wechseln. 16 | 17 | --------------- 18 | 19 | ## Neue Features 20 | - **Bilder-Download**: Bilder können nun pro Event heruntergeladen werden. 21 | - **Bessere Performance**: Die Objekterkennung arbeitet nun deutlich schneller. Bisher wurden etwa 3 Bilder pro Sekunde analysiert, mit dieser Version sind es 5-6 Bilder pro Sekunde! 22 | - **Wahl zwischen verschiedenen Modellen**: Es kann nun zwischen zwei verschiedenen Varianten zur Objekterkennung gewählt werden 23 | - **Einstellbare Verriegelung bei erkannter Beute**: Im Konfigurationsmenü lässt sich nun eine Sperrzeit für die Klappe festlegen, wenn eine Beute erkannt wurde. Ist die Sperrzeit aktiv, wird ein entsprechender Hinweis auf der Startseite angezeigt. Diese Sperrung kann über einen Button vorzeitig aufgehoben werden. 24 | 25 | ## Verbesserungen 26 | - **Speichern von Ereignissen bei Bewegung**: Ein Ereignis wird nur noch dann angelegt, wenn der äußere Bewegungsmelder eine Bewegung erkennt und in mindestens einem der Bilder der Wert für die `Minimale Erkennungsschwelle` (*Maus* oder *Keine Maus*) überschritten wurde. In diesem Fall werden **alle** zugehörigen Bilder während des Ereignisses gespeichert. 27 | - **Gepufferte Bilder**: Da der äußere Bewegungsmelder etwa 2-3 Sekunden benötigt, um eine Bewegung zu melden, werden Kamerabilder nun für mehrere Sekunden gepuffert. Dadurch sollten auch sehr schnelle Katzen zuverlässig in den ausgewerteten Bildern zu sehen sein. 28 | - **Neues Konfigurationsmenü**: Das Panel *Konfiguration* wurde übersichtlicher gestaltet. 29 | 30 | ## Bugfixes 31 | - **Kontinuierliche Auswertung**: Wenn die Mindestanzahl an auszuwertenden Bildern bereits erreicht ist und erst in einem der nachfolgenden Bilder eine Maus erkannt wird, wird die Klappe nun auch dann wieder verriegelt. 32 | - **Ausgehende Ereignisse**: Ein Fehler wurde behoben, durch den Ereignisse nicht gespeichert wurden, wenn eine Katze nach draußen ging. 33 | 34 | ## Hinweis zur Speicherverwaltung 35 | Da nun deutlich mehr Bilder gespeichert werden als in vorherigen Versionen, empfiehlt es sich, die `Maximale Anzahl an Bildern in der Datenbank` zu erhöhen. 36 | Je nach Ausstattung deiner Kittyflap (16 GB oder 32 GB Speicher) kannst du den Wert entsprechend anpassen. 37 | **8000** Bilder sollten in beiden Varianten problemlos möglich sein. 38 | Überwache den freien Speicherplatz am besten im **Info**-Panel. Als Richtwert gilt: Pro 1000 Bilder werden etwa **200 MB** Speicherplatz (100MB Datenbank + 100MB Backup) benötigt. 39 | -------------------------------------------------------------------------------- /doc/changelogs/changelog_v1.5.0_en.md: -------------------------------------------------------------------------------- 1 | # v1.5.0 2 | 3 | ## Highlights 4 | With version 1.5.0, the first preparations begin to allow you to adapt the "AI" to your cat and your environment! 5 | First, a lot of data needs to be collected to train the "AI" later. 6 | 7 | What does this mean in concrete terms? 8 | For reliable object detection, **at least** 100 images are required later for the category *No Mouse* and at least 100 images for the category *Mouse*. (Birds and other animals are simply classified as a mouse 😉). 9 | In the event view, there is now a download button that allows you to download all images of an event as a *.zip* file. 10 | You should collect as many different images as possible. The greater the variance later, the better – so collect photos during the day, at night, in bright sunlight with strong shadows, and so on. 11 | 12 | > **Note:** When downloading images as a *.zip* file, your browser (e.g., Google Chrome) may display a warning that the connection is not secure and block the download. This message is harmless as long as you are accessing the Kittyflap only from your home Wi-Fi. If necessary, you may need to confirm that you want to keep the file anyway. 13 | > A secure connection via HTTPS can help here. You can find guides online – one possible approach is a *Reverse Proxy*, such as the [NGINX Proxy Manager](https://nginxproxymanager.com/). 14 | 15 | Additionally, a new model variant for object detection has been selected as the default. This should reduce false detections of supposed mice on patio furniture or similar objects. Whether this model performs better in all situations cannot be stated universally – therefore, you can switch between the new and the old variant. 16 | 17 | --------------- 18 | 19 | ## New Features 20 | - **Image Download**: Images can now be downloaded per event. 21 | - **Better Performance**: Object detection now works significantly faster. Previously, about 3 images per second were analyzed; with this version, it's 5-6 images per second! 22 | - **Choose between different models**: It is now possible to choose between different variants of the object detection models 23 | - **Configurable Locking on Detected Prey**: In the configuration menu, a lock time for the flap can now be set when prey is detected. If the lock time is active, a corresponding notice is displayed on the homepage. This lock can be lifted early via a button. 24 | 25 | ## Improvements 26 | - **Saving Events on Motion**: An event is now only created if the external motion sensor detects movement and in at least one of the images the value for the `Minimum Detection Threshold` (*Mouse* or *No Mouse*) has been exceeded. In this case, **all** associated images during the event are saved. 27 | - **Buffered Images**: Since the external motion sensor takes about 2-3 seconds to report movement, camera images are now buffered for several seconds. This should ensure that even very fast cats are reliably captured in the evaluated images. 28 | - **New Configuration Menu**: The *Configuration* panel has been redesigned for better clarity. 29 | 30 | ## Bugfixes 31 | - **Continuous Evaluation**: If the minimum number of images to analyze is reached and a mouse is only detected in one of the subsequent images, the flap will now still be locked again. 32 | - **Outgoing Events**: A bug was fixed that prevented events from being saved when a cat went outside. 33 | 34 | ## Note on Storage Management 35 | Since significantly more images are now stored than in previous versions, it is recommended to increase the `Maximum Number of Images in the Database`. 36 | Depending on the specifications of your Kittyflap (16 GB or 32 GB storage), you can adjust the value accordingly. 37 | **8000** images should be easily possible in both variants. 38 | It is best to monitor the available storage space in the **Info** panel. As a guideline: **200 MB** of storage space is required for every **1000 images** (100MB database + 100MB backup). 39 | -------------------------------------------------------------------------------- /doc/changelogs/changelog_v1.5.1_de.md: -------------------------------------------------------------------------------- 1 | # v1.5.1 2 | 3 | ## Verbesserungen 4 | - **Bilder für Event-Ansicht werden jetzt gepuffert**: Die Bilder, die in der Event-Ansicht angezeigt werden, sind eine verkleinerte Version der in der Datenbank abgelegten Bilder. Diese wurden bisher on-the-fly generiert, sobald der Button mit der Lupe geklickt wurde. Bei Events mit vielen Bildern konnte es dadurch zu erheblichen Wartezeiten kommen. Diese verkleinerten Versionen werden nun direkt beim Erstellen neuer Bilder mit angelegt. 5 | > Hinweis: Bei Bildern in der Datenbank, die mit v1.5.0 oder früher angelegt wurden, fehlen diese Vorschauvarianten noch. 6 | > Diese werden im Hintergrund erst nach und nach angelegt, daher kann es direkt nach dem Update auf v1.5.1 einmalig noch etwas länger dauern, wenn du die Bilder eines Events ansehen möchtest. 7 | - **Fallback zu Objekterkennung auf nur einem CPU-Kern**: Falls es zu Neustarts oder System-Freezes kommt, wenn die Objekterkennung startet (ausgelöst durch einen der Bewegungsmelder), kannst du nun probehalber im Konfigurations-Panel die Berechnung auf nur einen CPU-Kern umstellen, statt auf alle Kerne (Neustart erforderlich). Näheres dazu in Issue #72. 8 | 9 | ## Bugfixes 10 | - **Anzahl Bilder pro Event limitiert**: Die maximale Anzahl an Bildern pro Ereignis ist nun limitiert. Das Limit kann im Konfigurations-Panel angepasst werden (jeweils für Bewegung mit erkannter RFID und für Bewegung ohne erkannter RFID). 11 | - **Internes Limit für Bilder-Cache**: Die maximale Anzahl für die intern gepufferten Bilder ist nun limitiert, um einen Speicherüberlauf zu verhindern. 12 | - **Sperrzeit nach Beuteerkennung**: Der Wert für die Sperrzeit nach Beuteerkennung wird im Konfigurations-Panel jetzt korrekt abgespeichert. 13 | - **Favicon auf Android und iOS**: Wenn du eine Verknüpfung zur Kittyhack-Seite auf den Homescreen deines Smartphones legst, wird nun das korrekte Favicon angezeigt. 14 | 15 | --------- 16 | 17 | ## Bekannte Probleme: 18 | - **Darstellung auf iOS**: Die Darstellung der Ereignis-Übersicht auf iOS-Geräten ist defekt und die Buttons zur Anzeige der Events funktionieren nicht. -------------------------------------------------------------------------------- /doc/changelogs/changelog_v1.5.1_en.md: -------------------------------------------------------------------------------- 1 | # v1.5.1 2 | 3 | ## Improvements 4 | - **Images for Event View are now cached**: The images displayed in the Event View are a smaller version of the images stored in the database. These were previously generated on-the-fly as soon as the magnifying glass button was clicked. For events with many images, this could lead to significant waiting times. These smaller versions are now created directly when new images are added. 5 | > Note: For images in the database that were added with v1.5.0 or earlier, these preview variants are still missing. 6 | > They will be created gradually in the background, so immediately after the update to v1.5.1, it may still take a little longer once when you want to view the images of an event. 7 | - **Fallback to object recognition on a single CPU core**: If there are restarts or system freezes when object recognition starts (triggered by one of the motion detectors), you can now try switching the calculation to a single CPU core instead of all cores in the configuration panel (restart required). More details in Issue #72. 8 | 9 | ## Bugfixes 10 | - **Limited number of images per event**: The maximum number of images per event is now limited. The limit can be adjusted in the configuration panel (separately for motion with recognized RFID and for motion without recognized RFID). 11 | - **Internal limit for image cache**: The maximum number of internally buffered images is now limited to prevent memory overflow. 12 | - **Lock time after prey recognition**: The value for the lock time after prey recognition is now correctly saved in the configuration panel. 13 | - **Favicon on Android and iOS**: If you add a shortcut to the Kittyhack site to your smartphone's home screen, the correct favicon will now be displayed. 14 | 15 | --------- 16 | 17 | ## Known Issues: 18 | - **Display on iOS**: The display of the event overview on iOS devices is broken and the buttons to display the events do not work. -------------------------------------------------------------------------------- /doc/changelogs/changelog_v1.5.2_de.md: -------------------------------------------------------------------------------- 1 | # v1.5.2 2 | 3 | ## Bugfixes 4 | - **Objekterkennung auf allen CPU-Kernen**: Diese Option ist nun standardmäßig deaktiviert, da sie bei einigen Kittyflaps zu unerwarteten Neustarts führte. Wenn sie bei dir bisher sicher funktioniert hat, kannst du sie einfach wieder im Konfigurations-Panel aktivieren. 5 | 6 | --------- 7 | 8 | ## Bekannte Probleme: 9 | - **Darstellung auf iOS**: Die Darstellung der Ereignis-Übersicht auf iOS-Geräten ist defekt und die Buttons zur Anzeige der Events funktionieren nicht. -------------------------------------------------------------------------------- /doc/changelogs/changelog_v1.5.2_en.md: -------------------------------------------------------------------------------- 1 | # v1.5.2 2 | 3 | ## Bugfixes 4 | - **Object detection on all CPU cores**: This option is now disabled by default, as it caused unexpected reboots on some Kittyflaps. If it has been working reliably for you, you can simply re-enable it in the configuration panel. 5 | 6 | --------- 7 | 8 | ## Known Issues: 9 | - **Display on iOS**: The display of the event overview on iOS devices is broken and the buttons to display the events do not work. -------------------------------------------------------------------------------- /doc/changelogs/changelog_v1.5.3_de.md: -------------------------------------------------------------------------------- 1 | # v1.5.3 2 | 3 | ## Verbesserungen 4 | - **Zeitbereiche für Ausgang**: Du kannst jetzt bis zu drei verschiedene Zeitbereiche festlegen, in denen deine Katzen nach draußen dürfen. 5 | 6 | --------- 7 | 8 | ## Bekannte Probleme: 9 | - **Darstellung auf iOS**: Die Darstellung der Ereignis-Übersicht auf iOS-Geräten ist defekt und die Buttons zur Anzeige der Events funktionieren nicht. -------------------------------------------------------------------------------- /doc/changelogs/changelog_v1.5.3_en.md: -------------------------------------------------------------------------------- 1 | # v1.5.3 2 | 3 | ## Improvements 4 | - **Time Ranges for Exit**: You can now define up to three different time ranges during which your cats are allowed to go outside. 5 | 6 | --------- 7 | 8 | ## Known Issues: 9 | - **Display on iOS**: The display of the event overview on iOS devices is broken and the buttons to display the events do not work. -------------------------------------------------------------------------------- /doc/changelogs/changelog_v2.0.0_de.md: -------------------------------------------------------------------------------- 1 | # v2.0.0 2 | 3 | ## Highlights 4 | Es ist soweit! Mit dieser Version hast du die Möglichkeit, die KI individuell auf deine Katze zu trainieren. Keine Chance mehr für Mäuse 😉 5 | 6 | --------------- 7 | 8 | ## Neue Features 9 | - **KI Training**: Individuelles Training der KI auf deine Katze und deine Umgebung. Mit Hilfe des eingebauten Label Studio Server kannst du kontinuierlich das Modell verbessern, das zur Auswertung der Kamerabilder verwendet wird. 10 | - **Entsperren über Bildauswertung**: Neben der Entsperrung anhand der RFID deiner Katze gibt es jetzt auch die Möglichkeit, die Klappe anhand der Bilderkennung zu entriegeln. 11 | - **Katzen-Thumbnail in Events**: In der "Events"-Ansicht wird nun ein Vorschaubild der erkannten Katze angezeigt 12 | 13 | ## Verbesserungen 14 | - **Design Anpassung**: Alle Tabs haben nun ein einheitliches Design. 15 | - **Übersetzung**: Alle Kacheln sind jetzt vollständig auf deutsch verfügbar. 16 | - **Automatischer Reconnect**: Wenn die Verbindung zur Kittyhack unterbrochen wurde (etwa weil du den Browser auf deinem Smartphone minimiert hast), wird diese nun automatisch wieder hergestellt. 17 | 18 | ## Bugfixes 19 | - **Darstellung auf iOS**: Die Darstellung der Event-Liste auf iOS Geräten wurde gefixt. 20 | - **RFID Reader**: Falsche RFIDs wie `E54` werden jetzt nicht mehr gelesen 21 | - **Uhrzeit in Events**: Die Uhrzeit in einem Event wird jetzt wieder korrekt dargestellt 22 | 23 | ## Kleinere Änderungen 24 | - **Logfile Download**: Es werden nun über einen Button alle relevanten Logs auf einmal heruntergeladen 25 | - **Absturzerkennung**: Die Software erkennt nun, ob es unerwartete Abstürze gab. Bei mehreren aufeinanderfolgenden Abstürzen wird eine Warnmeldung angezeigt. 26 | - **Update-Prozess**: Der Update-Prozess gibt jetzt detailliertere Ausgaben über seinen aktuellen Status aus. -------------------------------------------------------------------------------- /doc/changelogs/changelog_v2.0.0_en.md: -------------------------------------------------------------------------------- 1 | # v2.0.0 2 | 3 | ## Highlights 4 | The time has come! With this version, you have the ability to individually train the AI for your cat. No more chance for mice 😉 5 | 6 | --------------- 7 | 8 | ## New Features 9 | - **AI Training**: Individual training of the AI for your cat and your environment. With the help of the built-in Label Studio Server, you can continuously improve the model used to evaluate camera images. 10 | - **Unlocking via Image Analysis**: In addition to unlocking using your cat's RFID, there is now the option to unlock the flap based on image recognition. 11 | - **Cat Thumbnail in Events**: The "Events" view now displays a thumbnail image of the detected cat 12 | 13 | ## Improvements 14 | - **Design Adjustment**: All tabs now have a uniform design. 15 | - **Translation**: All tiles are now fully available in German. 16 | - **Automatic Reconnect**: If the connection to Kittyhack is interrupted (for example because you minimized the browser on your smartphone), it will now be automatically restored. 17 | 18 | ## Bugfixes 19 | - **Display on iOS**: The display of the event list on iOS devices has been fixed. 20 | - **RFID Reader**: Incorrect RFIDs like `E54` are no longer read 21 | - **Time in Events**: The time in an event is now displayed correctly again 22 | 23 | ## Minor Changes 24 | - **Logfile Download**: All relevant logs can now be downloaded at once via a button 25 | - **Crash Detection**: The software now recognizes if unexpected crashes occurred. If there are multiple consecutive crashes, a warning message is displayed. 26 | - **Update Process**: The update process now provides more detailed output about its current status. -------------------------------------------------------------------------------- /doc/changelogs/changelog_v2.0.1_de.md: -------------------------------------------------------------------------------- 1 | # v2.0.1 2 | 3 | ## Bugfixes 4 | - **Loggen von Events mit RFID**: Events werden jetzt immer gespeichert, wenn eine RFID ausgelesen werden konnte - selbst wenn die `Minimale Erkennungsschwelle` für die Objekterkennung nicht erreicht wurde. 5 | - **RFID-Reader**: Falsche RFIDs wie `E54` werden jetzt nicht mehr gelesen (Das Problem sollte bereits in v2.0.0 behoben sein, es hat sich aber noch ein weiterer Fehler eingeschlichen) 6 | - **Fehlender Button**: Der fehlende `Changelogs`-Button im Info-Tab ist jetzt wieder verfügbar -------------------------------------------------------------------------------- /doc/changelogs/changelog_v2.0.1_en.md: -------------------------------------------------------------------------------- 1 | # v2.0.1 2 | 3 | ## Bugfixes 4 | - **Logging of events with RFID**: Events are now always saved when an RFID could be read - even if the `Minimum detection threshold` for object recognition has not been reached. 5 | - **RFID Reader**: Incorrect RFIDs like `E54` are no longer read (The issue should have been fixed in v2.0.0, but another error had crept in) 6 | - **Missing Button**: The missing `Changelogs` button in the Info tab is now available again -------------------------------------------------------------------------------- /doc/changelogs/changelog_v2.1.0_de.md: -------------------------------------------------------------------------------- 1 | # v2.1.0 2 | 3 | ## Neue Features 4 | - **PWA Support**: Die Kittyflap-Benutzeroberfläche kann jetzt als Progressive Web App auf Mobilgeräten und Desktops installiert werden. So hast du einen schnelleren Zugriff auf die Katzenklappe ohne den Browser öffnen zu müssen (Für diese Funktion wird eine HTTPS-Verbindung benötigt) 5 | > ℹ️ Weitere Infos dazu im **`Info`**-Tab 6 | - **Konfiguration des Hostnamen**: Der Hostname der Kittyflap kann jetzt über die Einstellungen angepasst werden. Dies ermöglicht einen einfacheren Zugriff auf die Katzenklappe über einen benutzerdefinierten Namen im lokalen Netzwerk (z.B. `http://kittyflap.local`). 7 | - **Bewegungserkennung per Kamera**: Alternativ zum äußeren PIR-Sensor kann nun auch das Kamerabild zur Erkennung von Bewegung genutzt werden. Dies reduziert Fehlauslösungen durch Bewegungen von Bäumen oder Menschen im Bild deutlich. Voraussetzung ist jedoch ein gut trainiertes, eigenes Erkennungsmodell. 8 | > ℹ️ Die Option findest du unter **`Konfiguration`** -> **`Kamera für die Bewegungserkennung verwenden`**. 9 | 10 | 11 | ## Verbesserungen 12 | - **Zusätzliche Event Infos**: Die Event-Liste zeigt jetzt zusätzliche Icons bei manuellen Entriegelungen / Verriegelungen während eines Events an. 13 | - **Konfigurations-Tab**: Geänderte Eingabefelder im Konfigurations-Tab werden nun vor dem Speichern optisch hervorgehoben. Dadurch sind Fehleingaben (z. B. beim Scrollen auf Smartphones) leichter erkennbar. 14 | 15 | ## Bugfixes 16 | - **RFID-Reader**: Das Ausleseverhalten des RFID-Readers wurde weiter verbessert 17 | -------------------------------------------------------------------------------- /doc/changelogs/changelog_v2.1.0_en.md: -------------------------------------------------------------------------------- 1 | # v2.1.0 2 | 3 | ## New Features 4 | - **PWA Support**: The Kittyflap user interface can now be installed as a Progressive Web App on mobile devices and desktops. This gives you faster access to the cat flap without having to open the browser (This feature requires an HTTPS connection) 5 | > ℹ️ More information can be found in the **`Info`** tab. 6 | - **Hostname Configuration**: The hostname of the Kittyflap can now be customized through the settings. This enables easier access to the cat flap via a custom name in the local network (e.g., `http://kittyflap.local`). 7 | - **Motion Detection via Camera**: As an alternative to the PIR sensor on the outside, the camera image can now be used to detect motion. This significantly reduces false triggers caused by moving trees or people in the image. However, a well-trained, custom detection model is required. 8 | > ℹ️ You can find this option under **`Configuration`** -> **`Use camera for motion detection`**. 9 | 10 | ## Improvements 11 | - **Additional Event Info**: The event list now displays additional icons for manual unlocking/locking during an event. 12 | - **Configuration tab**: Changed input fields in the configuration tab are now visually highlighted before saving. This makes input errors (e.g., when scrolling on smartphones) easier to spot. 13 | 14 | ## Bugfixes 15 | - **RFID-Reader**: The reading behavior of the RFID reader has been further improved -------------------------------------------------------------------------------- /doc/changelogs/changelog_v2.1.1_de.md: -------------------------------------------------------------------------------- 1 | # v2.1.1 2 | 3 | ## Bugfixes 4 | - **Neuinstallation**: Nach einer Neuinstallation kam es zu Abstürzen, wenn noch keine Datenbank oder keine Konfiguration vorhanden war. 5 | - **(Mobil) Navigationsleiste**: Die Navigationsleiste klappt auf Smartphones nun automatisch ein, nachdem im Webinterface zu einem anderen Tab gewechselt wurde. 6 | - **PWA**: Bei Verwendung des Webinterfaces als PWA (Progressive Web App) wird nun automatisch versucht, die Verbindung wiederherzustellen, wenn diese unterbrochen wurde. (Diese Änderung erfordert ein einmaliges Leeren des Browser-Caches, damit sie funktioniert.) 7 | 8 | ## Kleinere Änderungen 9 | - **Maximale Sperrzeit erhöht**: Die maximal konfigurierbare Sperrzeit bei erkannter Beute wurde von 10 auf 30 Minuten erhöht. -------------------------------------------------------------------------------- /doc/changelogs/changelog_v2.1.1_en.md: -------------------------------------------------------------------------------- 1 | # v2.1.1 2 | 3 | ## Bugfixes 4 | - **New installation**: After a new installation, crashes could occur if no database or configuration was present. 5 | - **(Mobile) Navigation bar**: On smartphones, the navigation bar now automatically collapses after switching to another tab in the web interface. 6 | - **PWA**: When using the web interface as a PWA (Progressive Web App), the app now automatically tries to restore the connection if it is interrupted. (This change requires clearing the browser cache once for it to take effect.) 7 | 8 | ## Minor changes 9 | - **Maximum lock duration increased**: The maximum configurable lock duration after prey detection has been increased from 10 to 30 minutes. -------------------------------------------------------------------------------- /doc/changelogs/changelog_v2.2.0_de.md: -------------------------------------------------------------------------------- 1 | # v2.2.0 2 | 3 | ## Neue Features 4 | - **IP-Kamera-Support**: Neben der eingebauten Kamera können jetzt auch externe IP-Kameras verwendet werden. Dadurch ist eine flexiblere Positionierung möglich, z. B. für einen besseren Blickwinkel auf die Beute im Maul der Katze. 5 | 6 | ## Verbesserungen 7 | - **Konfigurations-Tab**: Die Einstellungen sind jetzt übersichtlicher in aufklappbaren Containern gruppiert. 8 | - **Update-Prozess**: Der Update-Vorgang über das Web-Interface wurde verbessert (diese Änderung greift ab dem nächsten Update nach dieser Version): 9 | - Keine Verbindungsabbrüche mehr während des Updates. 10 | - Vor dem Update wird die Funktion der Klappe gesperrt, um Störungen zu vermeiden -------------------------------------------------------------------------------- /doc/changelogs/changelog_v2.2.0_en.md: -------------------------------------------------------------------------------- 1 | # v2.2.0 2 | 3 | ## New Features 4 | - **IP Camera Support**: In addition to the built-in camera, external IP cameras can now be used. This allows for more flexible positioning, e.g., for a better view of the prey in the cat's mouth. 5 | 6 | ## Improvements 7 | - **Configuration Tab**: The settings are now more clearly organized in collapsible containers. 8 | - **Update Process**: The update process via the web interface has been improved (this change will take effect from the next update after this version): 9 | - No more connection drops during updates. 10 | - Before updating, the flap function is locked to prevent interference. -------------------------------------------------------------------------------- /doc/changelogs/template.md: -------------------------------------------------------------------------------- 1 | # vX.Y.Z 2 | 3 | 4 | ## Highlights 5 | 6 | --------------- 7 | 8 | ## Neue Features 9 | - **Schlagwort**: Beschreibung 10 | 11 | ## Verbesserungen 12 | - **Schlagwort**: Beschreibung 13 | 14 | ## Bugfixes 15 | - **Schlagwort**: Beschreibung 16 | 17 | ## Kleinere Änderungen 18 | - **Schlagwort**: Beschreibung 19 | -------------------------------------------------------------------------------- /doc/free-disk-space-example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/doc/free-disk-space-example.jpg -------------------------------------------------------------------------------- /doc/kittyflap-hostname.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/doc/kittyflap-hostname.png -------------------------------------------------------------------------------- /doc/swap-resize.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/doc/swap-resize.jpg -------------------------------------------------------------------------------- /locales/de/LC_MESSAGES/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/locales/de/LC_MESSAGES/.gitkeep -------------------------------------------------------------------------------- /locales/de/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/locales/de/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /locales/en/LC_MESSAGES/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/locales/en/LC_MESSAGES/.gitkeep -------------------------------------------------------------------------------- /locales/en/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/locales/en/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /locales/readme.md: -------------------------------------------------------------------------------- 1 | # Initial Setup 2 | 3 | *NOTE:* Run these commands in the project root folder! 4 | The steps below require gettext (on ubuntu systems install it with `apt install gettext`). 5 | 6 | ##### Initially create POT file: 7 | ``` 8 | xgettext -d messages -o locales/messages.pot src/server.py --from-code UTF-8 9 | xgettext -d messages -o locales/messages.pot src/ui.py --from-code UTF-8 --join-existing 10 | xgettext -d messages -o locales/messages.pot src/helper.py --from-code UTF-8 --join-existing 11 | xgettext -d messages -o locales/messages.pot src/model.py --from-code UTF-8 --join-existing 12 | xgettext -d messages -o locales/messages.pot src/system.py --from-code UTF-8 --join-existing 13 | ``` 14 | 15 | ##### Initially create po file: 16 | ``` 17 | msginit -l de_DE.UTF-8 -o locales/de/LC_MESSAGES/messages.po -i locales/messages.pot --no-translator 18 | msginit -l en_EN.UTF-8 -o locales/en/LC_MESSAGES/messages.po -i locales/messages.pot --no-translator 19 | ``` 20 | 21 | ##### Create mo file from po file: 22 | ``` 23 | msgfmt -o locales/de/LC_MESSAGES/messages.mo locales/de/LC_MESSAGES/messages.po 24 | msgfmt -o locales/en/LC_MESSAGES/messages.mo locales/en/LC_MESSAGES/messages.po 25 | ``` 26 | 27 | # Update locales 28 | 29 | ##### Update POT file: 30 | ``` 31 | xgettext -d messages -o locales/messages.pot src/server.py --from-code UTF-8 32 | xgettext -d messages -o locales/messages.pot src/ui.py --from-code UTF-8 --join-existing 33 | xgettext -d messages -o locales/messages.pot src/helper.py --from-code UTF-8 --join-existing 34 | xgettext -d messages -o locales/messages.pot src/model.py --from-code UTF-8 --join-existing 35 | xgettext -d messages -o locales/messages.pot src/system.py --from-code UTF-8 --join-existing 36 | ``` 37 | 38 | ##### Merge existing po file with new values from POT: 39 | ``` 40 | msgmerge -U locales/de/LC_MESSAGES/messages.po locales/messages.pot 41 | msgmerge -U locales/en/LC_MESSAGES/messages.po locales/messages.pot 42 | ``` 43 | 44 | ##### Update mo file: 45 | ``` 46 | msgfmt -o locales/de/LC_MESSAGES/messages.mo locales/de/LC_MESSAGES/messages.po 47 | msgfmt -o locales/en/LC_MESSAGES/messages.mo locales/en/LC_MESSAGES/messages.po 48 | ``` -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio==4.6.2.post1 2 | appdirs==1.4.4 3 | asgiref==3.8.1 4 | certifi==2024.8.30 5 | charset-normalizer==3.4.0 6 | click==8.1.7 7 | colorama==0.4.6 8 | configparser==7.1.0 9 | ConfigUpdater==3.2 10 | contourpy==1.3.1 11 | cycler==0.12.1 12 | DateTime==5.5 13 | faicons==0.2.2 14 | filelock==3.18.0 15 | fonttools==4.57.0 16 | fsspec==2025.3.2 17 | h11==0.14.0 18 | htmltools==0.6.0 19 | idna==3.10 20 | Jinja2==3.1.4 21 | kiwisolver==1.4.8 22 | linkify-it-py==2.0.3 23 | markdown-it-py==3.0.0 24 | MarkupSafe==3.0.2 25 | matplotlib==3.10.1 26 | mdit-py-plugins==0.4.2 27 | mdurl==0.1.2 28 | mpmath==1.3.0 29 | narwhals==1.13.5 30 | ncnn==1.0.20241226 31 | networkx==3.4.2 32 | numpy==1.26.4 33 | opencv-python==4.11.0.86 34 | opencv-python-headless==4.10.0.84 35 | orjson==3.10.11 36 | packaging==24.2 37 | pandas==2.2.3 38 | pillow==11.1.0 39 | portalocker==3.1.1 40 | prompt-toolkit==3.0.36 41 | psutil==7.0.0 42 | py-cpuinfo==9.0.0 43 | pyparsing==3.2.3 44 | python-dateutil==2.9.0.post0 45 | python-multipart==0.0.17 46 | pytz==2024.2 47 | PyYAML==6.0.2 48 | questionary==2.0.1 49 | requests==2.32.3 50 | scipy==1.15.2 51 | seaborn==0.13.2 52 | shiny==1.2.1 53 | six==1.16.0 54 | sniffio==1.3.1 55 | starlette==0.41.2 56 | sympy==1.13.1 57 | tflite-runtime==2.14.0 58 | torch==2.6.0 59 | torchvision==0.21.0 60 | tqdm==4.67.1 61 | typing_extensions==4.12.2 62 | tzdata==2024.2 63 | uc-micro-py==1.0.3 64 | ultralytics==8.3.101 65 | ultralytics-thop==2.0.14 66 | urllib3==2.2.3 67 | uvicorn==0.32.0 68 | watchfiles==0.24.0 69 | wcwidth==0.2.13 70 | websockets==14.1 71 | zope.interface==7.1.1 -------------------------------------------------------------------------------- /setup/kittyhack.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=KittyHack WebGUI for Kittyflap 3 | After=network.target time-sync.target 4 | Wants=time-sync.target 5 | 6 | [Service] 7 | User=root 8 | Group=root 9 | WorkingDirectory=/root/kittyhack 10 | Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/kittyhack/.venv/bin" 11 | ExecStartPre=/bin/sh -c 'if timeout 90 bash -c "until [ \"$(timedatectl show --property=NTPSynchronized --value)\" = yes ]; do sleep 1; done"; then echo "Time synchronized successfully"; else echo "Time sync failed, continuing anyway"; fi || true' 12 | ExecStart=/root/kittyhack/.venv/bin/shiny run --host=0.0.0.0 --port=80 13 | Restart=always 14 | RestartSec=5 15 | 16 | # Process handling 17 | KillSignal=SIGTERM 18 | KillMode=mixed 19 | TimeoutStopSec=30 20 | SuccessExitStatus=SIGKILL 21 | 22 | [Install] 23 | WantedBy=multi-user.target 24 | -------------------------------------------------------------------------------- /setup/labelstudio.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Label Studio Annotation Tool 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | User=root 8 | WorkingDirectory=/root/labelstudio 9 | ExecStart=/root/labelstudio/venv/bin/label-studio --port 8080 --host 0.0.0.0 --log-level INFO 10 | Restart=always 11 | Environment="LABEL_STUDIO_DATABASE_DIR=/root/labelstudio/data" 12 | Environment="LABEL_STUDIO_ACCESS_LOGS=1" 13 | 14 | [Install] 15 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /src/backend.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time as tm 3 | import logging 4 | import multiprocessing 5 | from src.baseconfig import AllowedToEnter, CONFIG 6 | from src.pir import Pir 7 | from src.database import * 8 | from src.magnets_rfid import Magnets, Rfid, RfidRunState 9 | from src.camera import image_buffer 10 | from src.helper import sigterm_monitor, EventType, check_allowed_to_exit 11 | from src.model import ModelHandler, YoloModel 12 | 13 | TAG_TIMEOUT = 30.0 # after 30 seconds, a detected tag is considered invalid 14 | RFID_READER_OFF_DELAY = 15.0 # Turn the RFID reader off 15 seconds after the last detected motion outside 15 | OPEN_OUTSIDE_TIMEOUT = 6.0 + CONFIG['PIR_INSIDE_THRESHOLD'] # Keep the magnet to the outside open for 6 + PIR_INSIDE_THRESHOLD seconds after the last motion on the inside 16 | MAX_UNLOCK_TIME = 60.0 # Maximum time the door is allowed to stay open 17 | LAZY_CAT_DELAY_PIR_MOTION = 6.0 # Keep the PIR active for an additional 6 seconds after the last detected motion when using PIR-based motion detection 18 | LAZY_CAT_DELAY_CAM_MOTION = 12.0 # Keep the PIR active for an additional 12 seconds after the last detected motion when using camera-based motion detection 19 | 20 | # Initialize Model 21 | if CONFIG['USE_ALL_CORES_FOR_IMAGE_PROCESSING']: 22 | threads = multiprocessing.cpu_count() 23 | else: 24 | threads = 1 25 | 26 | if CONFIG['TFLITE_MODEL_VERSION']: 27 | logging.info(f"[BACKEND] Using TFLite model version {CONFIG['TFLITE_MODEL_VERSION']}") 28 | model_handler = ModelHandler(model="tflite", 29 | modeldir = f"./tflite/{CONFIG['TFLITE_MODEL_VERSION']}", 30 | graph = "cv-lite-model.tflite", 31 | labelfile = "labels.txt", 32 | model_image_size = 320, 33 | num_threads=threads) 34 | else: 35 | logging.info(f"[BACKEND] Using YOLO model {YoloModel.get_model_path(CONFIG['YOLO_MODEL'])}") 36 | model_handler = ModelHandler(model="yolo", 37 | modeldir = YoloModel.get_model_path(CONFIG['YOLO_MODEL']), 38 | graph = "", 39 | labelfile = "labels.txt", 40 | resolution = "800x600", 41 | framerate = 10, 42 | jpeg_quality = 75, 43 | model_image_size = YoloModel.get_model_image_size(CONFIG['YOLO_MODEL']), 44 | num_threads=threads) 45 | 46 | # Global variable for manual door control 47 | manual_door_override = {'unlock_inside': False, 'unlock_outside': False, 'lock_inside': False, 'lock_outside': False} 48 | 49 | def backend_main(simulate_kittyflap = False): 50 | 51 | global manual_door_override 52 | 53 | tag_id = None 54 | tag_id_valid = False 55 | tag_id_from_video = None 56 | tag_timestamp = 0.0 57 | motion_outside = 0 58 | motion_inside = 0 59 | motion_outside_raw = 0 60 | motion_inside_raw = 0 61 | unlock_inside_decision_made = False 62 | motion_outside_tm = 0.0 63 | motion_inside_tm = 0.0 64 | motion_inside_raw_tm = 0.0 65 | last_motion_outside_tm = 0.0 66 | last_motion_inside_tm = 0.0 67 | last_motion_inside_raw_tm = 0.0 68 | first_motion_outside_tm = 0.0 69 | first_motion_inside_tm = 0.0 70 | first_motion_inside_raw_tm = 0.0 71 | motion_block_id = 0 72 | ids_with_mouse = [] 73 | ids_of_current_motion_block = [] 74 | known_rfid_tags = [] 75 | cat_rfid_name_dict = {} 76 | unlock_inside_tm = 0.0 77 | unlock_inside = False 78 | unlock_outside_tm = 0.0 79 | inside_manually_unlocked = False 80 | backend_main.prey_detection_tm = 0.0 81 | additional_verdict_infos = [] 82 | previous_use_camera_for_motion = None 83 | 84 | # Register task in the sigterm_monitor object 85 | sigterm_monitor.register_task() 86 | 87 | # Start the camera 88 | logging.info("[BACKEND] Start the camera...") 89 | def run_camera(): 90 | model_handler.run() 91 | 92 | # Run the camera in a separate thread 93 | camera_thread = threading.Thread(target=run_camera, daemon=True) 94 | camera_thread.start() 95 | model_handler.pause() 96 | 97 | # Initialize PIRs, Magnets and RFID 98 | pir = Pir(simulate_kittyflap=simulate_kittyflap) 99 | pir.init() 100 | rfid = Rfid(simulate_kittyflap=simulate_kittyflap) 101 | magnets = Magnets(simulate_kittyflap=simulate_kittyflap) 102 | magnets.init() 103 | 104 | logging.info("[BACKEND] Wait for the sensors to stabilize...") 105 | tm.sleep(5.0) 106 | 107 | # Start the magnet control thread 108 | magnets.start_magnet_control() 109 | 110 | # Start PIR monitoring thread 111 | pir_thread = threading.Thread(target=pir.read, args=(), daemon=True) 112 | pir_thread.start() 113 | 114 | # Start the RFID reader (without the field enabled) 115 | rfid_thread = threading.Thread(target=rfid.run, args=(), daemon=True) 116 | rfid_thread.start() 117 | 118 | def lazy_cat_workaround(current_motion_state: int | bool, last_motion_state: int | bool, current_motion_timestamp: float, delay=LAZY_CAT_DELAY_PIR_MOTION) -> int | bool: 119 | """ 120 | Helps to keep a PIR sensor active for an additional configurable seconds after the last detected motion. 121 | 122 | Args: 123 | current_motion_state (int): The current state of motion detection (1 for motion detected, 0 for no motion). 124 | last_motion_state (int): The previous state of motion detection (1 for motion detected, 0 for no motion). 125 | current_motion_timestamp (float): The timestamp of the current motion detection event. 126 | delay (float): The additional delay in seconds to keep the PIR active after the last detected motion outside. 127 | 128 | Returns: 129 | int: The possibly modified current motion state, ensuring the PIR remains active for an additional configurable seconds 130 | if the conditions are met. 131 | """ 132 | if ( (current_motion_state == 0) and 133 | (last_motion_state == 1) and 134 | ((tm.time() - current_motion_timestamp) < delay) ): 135 | current_motion_state = 1 136 | logging.debug(f"[BACKEND] Lazy cat workaround: Keep the PIR active for {delay-(tm.time()-current_motion_timestamp):.1f} seconds.") 137 | return current_motion_state 138 | 139 | while not sigterm_monitor.stop_now: 140 | try: 141 | tm.sleep(0.1) # sleep to reduce CPU load 142 | 143 | # Decide if the camera or the PIR should be used for motion detection 144 | use_camera_for_motion = CONFIG['USE_CAMERA_FOR_MOTION_DETECTION'] 145 | 146 | # Log if the configuration has changed 147 | if use_camera_for_motion != previous_use_camera_for_motion and model_handler.check_videostream_status(): 148 | previous_use_camera_for_motion = use_camera_for_motion 149 | if use_camera_for_motion: 150 | motion_source = "Camera" 151 | model_handler.set_videostream_buffer_size(1) 152 | # Check whether the model handler is running. If not, start it. 153 | if model_handler.get_run_state() == False: 154 | logging.info("[BACKEND] Starting model handler for camera-based motion detection.") 155 | model_handler.resume() 156 | tm.sleep(0.5) 157 | else: 158 | motion_source = "PIR" 159 | model_handler.set_videostream_buffer_size(30) 160 | if motion_outside == 0 and model_handler.get_run_state() == True: 161 | logging.info("[BACKEND] Currently no motion outside detected. Pausing model handler for PIR-based motion detection.") 162 | model_handler.pause() 163 | logging.info(f"[BACKEND] Outside motion detection mode changed to {motion_source}.") 164 | image_buffer.clear() # Clear the image buffer when switching motion detection mode 165 | 166 | last_outside = motion_outside 167 | last_inside = motion_inside 168 | last_inside_raw = motion_inside_raw 169 | 170 | if use_camera_for_motion: 171 | # Decide if motion occured currently. Look up to 5 seconds into the past for images with cats 172 | min_ts = tm.time() - 5.0 173 | cat_imgs = image_buffer.get_filtered_ids(min_timestamp=min_ts, min_own_cat_probability=CONFIG['CAT_THRESHOLD']) 174 | motion_outside = 1 if len(cat_imgs) > 0 else 0 175 | # Motion raw does not exist for the camera, so we set it to the same value as motion_outside 176 | motion_outside_raw = motion_outside 177 | # Still use PIR for inside motion 178 | __, motion_inside, __, motion_inside_raw = pir.get_states() 179 | else: 180 | motion_outside, motion_inside, motion_outside_raw, motion_inside_raw = pir.get_states() 181 | 182 | # Update the motion timestamps 183 | if motion_outside == 1: 184 | motion_outside_tm = tm.time() 185 | if motion_inside == 1: 186 | motion_inside_tm = tm.time() 187 | if motion_inside_raw == 1: 188 | motion_inside_raw_tm = tm.time() 189 | 190 | if use_camera_for_motion: 191 | # If we use the camera for motion detection, keep the outside motion-indicator longer active, since normally 192 | # no motion is detected anymore by the camera, when the cat is very close to the flap. 193 | motion_outside = lazy_cat_workaround(motion_outside, last_outside, motion_outside_tm, LAZY_CAT_DELAY_CAM_MOTION) 194 | else: 195 | # Since the PIR tracks motion in a wider area (and even directly in front of the flap), we do not need to keep 196 | # the motion active as long as with the camera motion detection 197 | motion_outside = lazy_cat_workaround(motion_outside, last_outside, motion_outside_tm, LAZY_CAT_DELAY_PIR_MOTION) 198 | 199 | motion_inside = lazy_cat_workaround(motion_inside, last_inside, motion_inside_tm, LAZY_CAT_DELAY_PIR_MOTION) 200 | motion_inside_raw = lazy_cat_workaround(motion_inside_raw, last_inside_raw, motion_inside_raw_tm, LAZY_CAT_DELAY_PIR_MOTION) 201 | 202 | previous_tag_id = tag_id 203 | tag_id, tag_timestamp = rfid.get_tag() 204 | 205 | # Check if the RFID reader is still running. Otherwise restart it. 206 | if rfid.get_run_state() == RfidRunState.stopped: 207 | logging.warning("[BACKEND] RFID reader stopped unexpectedly. Restarting RFID reader.") 208 | rfid_thread = threading.Thread(target=rfid.run, args=(), daemon=True) 209 | rfid_thread.start() 210 | 211 | # Outside motion stopped 212 | if last_outside == 1 and motion_outside == 0: 213 | if not use_camera_for_motion: 214 | if model_handler.get_run_state() == True: 215 | model_handler.pause() 216 | # Wait for the last image to be processed 217 | tm.sleep(0.5) 218 | unlock_inside_decision_made = False 219 | tag_id_valid = False 220 | last_motion_outside_tm = tm.time() 221 | logging.info(f"[BACKEND] {motion_source}-based motion detection: Motion stopped OUTSIDE (Block ID: '{motion_block_id}')") 222 | if (magnets.get_inside_state() == True and magnets.check_queued("lock_inside") == False and inside_manually_unlocked == False): 223 | magnets.queue_command("lock_inside") 224 | 225 | # Decide if the cat went in or out: 226 | if first_motion_inside_raw_tm == 0.0 or (first_motion_outside_tm - first_motion_inside_raw_tm) > 60.0: 227 | if unlock_inside_tm > first_motion_outside_tm and tag_id is not None: 228 | logging.info("[BACKEND] Motion event conclusion: No motion inside detected but the inside was unlocked. Cat went probably to the inside (PIR interference issue).") 229 | event_type = EventType.CAT_WENT_PROBABLY_INSIDE 230 | elif mouse_check_conditions["no_mouse_detected"]: 231 | logging.info("[BACKEND] Motion event conclusion: No one went inside.") 232 | event_type = EventType.MOTION_OUTSIDE_ONLY 233 | else: 234 | logging.info("[BACKEND] Motion event conclusion: Motion outside with mouse detected and entry blocked.") 235 | event_type = EventType.MOTION_OUTSIDE_WITH_MOUSE 236 | elif first_motion_outside_tm < first_motion_inside_raw_tm: 237 | if mouse_check_conditions["no_mouse_detected"]: 238 | logging.info("[BACKEND] Motion event conclusion: Cat went inside.") 239 | event_type = EventType.CAT_WENT_INSIDE 240 | else: 241 | logging.info("[BACKEND] Motion event conclusion: Cat went inside with mouse detected.") 242 | event_type = EventType.CAT_WENT_INSIDE_WITH_MOUSE 243 | else: 244 | logging.info("[BACKEND] Motion event conclusion: Cat went outside.") 245 | event_type = EventType.CAT_WENT_OUTSIDE 246 | 247 | if use_camera_for_motion: 248 | if event_type == EventType.CAT_WENT_OUTSIDE: 249 | # Use either the first_motion_outside_tm or the first_motion_inside_tm+2.5, whichever is earlier, as the timestamp for the event 250 | log_start_tm = min(first_motion_outside_tm, first_motion_inside_tm + 2.5) 251 | else: 252 | # Log 2.5 seconds earlier, since the camera might not detect the cat immediately 253 | log_start_tm = first_motion_outside_tm - 2.5 254 | else: 255 | # Don't log earlier than the first motion outside timestamp when using PIR-based motion detection 256 | log_start_tm = first_motion_outside_tm 257 | 258 | 259 | all_events = str(event_type) 260 | # Add all additional verdict information to the event type 261 | if additional_verdict_infos: 262 | for info in additional_verdict_infos: 263 | all_events += "," + str(info) 264 | additional_verdict_infos = [] 265 | 266 | # Update the motion_block_id and the tag_id for for all elements between log_start_tm and last_motion_outside_tm 267 | img_ids_for_motion_block = image_buffer.get_filtered_ids(log_start_tm, last_motion_outside_tm) 268 | ids_exceeding_mouse_th = image_buffer.get_filtered_ids(log_start_tm, last_motion_outside_tm, min_mouse_probability=CONFIG['MIN_THRESHOLD']) 269 | ids_exceeding_nomouse_th = image_buffer.get_filtered_ids(log_start_tm, last_motion_outside_tm, min_no_mouse_probability=CONFIG['MIN_THRESHOLD']) 270 | ids_exceeding_own_cat_th = image_buffer.get_filtered_ids(log_start_tm, last_motion_outside_tm, min_own_cat_probability=CONFIG['MIN_THRESHOLD']) 271 | logging.info(f"""[BACKEND] {motion_source}-based motion detection: Detection summary: 272 | - {len(img_ids_for_motion_block)} elements in current motion block (between {first_motion_outside_tm} and {last_motion_outside_tm}) 273 | - {len(ids_exceeding_mouse_th)} elements where "mouse" detection exceeded the min. logging threshold of {CONFIG['MIN_THRESHOLD']} 274 | - {len(ids_exceeding_nomouse_th)} elements where "no-mouse" detection exceeded the min. logging threshold of {CONFIG['MIN_THRESHOLD']} 275 | - {len(ids_exceeding_own_cat_th)} elements where "own cat" detection exceeded the min. logging threshold of {CONFIG['MIN_THRESHOLD']} 276 | Event type: {all_events} 277 | RFID tag: {tag_id or 'None'} 278 | Video tag: {tag_id_from_video or 'None'}""") 279 | # Log all events to the database, where either the mouse threshold is exceeded, the no-mouse threshold is exceeded, 280 | # or the own cat threshold is exceeded or a tag id was detected 281 | # as well as all outgoing events 282 | if ((len(ids_exceeding_mouse_th) + len(ids_exceeding_nomouse_th) +len(ids_exceeding_own_cat_th) > 0) or 283 | (event_type in [EventType.CAT_WENT_OUTSIDE]) or 284 | (tag_id is not None) or 285 | (tag_id_from_video is not None)): 286 | for element in img_ids_for_motion_block: 287 | image_buffer.update_block_id(element, motion_block_id) 288 | # Prefer the tag_id from the RFID reader. If this is not available, fall back to the detected id from the video 289 | if tag_id is not None: 290 | image_buffer.update_tag_id(element, tag_id) 291 | elif tag_id_from_video is not None: 292 | image_buffer.update_tag_id(element, tag_id_from_video) 293 | logging.info(f"[BACKEND] Minimal threshold exceeded or tag ID detected. Images will be written to the database. Updated block ID for {len(img_ids_for_motion_block)} elements to '{motion_block_id}' and tag ID to '{tag_id if tag_id is not None else ''}'") 294 | # Write to the database in a separate thread 295 | db_thread = threading.Thread(target=write_motion_block_to_db, args=(CONFIG['KITTYHACK_DATABASE_PATH'], motion_block_id, all_events), daemon=True) 296 | db_thread.start() 297 | else: 298 | logging.info(f"[BACKEND] No elements found that exceed the minimal threshold '{CONFIG['MIN_THRESHOLD']}' and no tag ID was detected. No database entry will be created.") 299 | if len(img_ids_for_motion_block) > 0: 300 | for element in img_ids_for_motion_block: 301 | image_buffer.delete_by_id(element) 302 | 303 | # Reset the first motion timestamps 304 | first_motion_outside_tm = 0.0 305 | first_motion_inside_tm = 0.0 306 | first_motion_inside_raw_tm = 0.0 307 | 308 | # Forget the video tag id 309 | tag_id_from_video = None 310 | if tag_id is not None: 311 | rfid.set_tag(None, 0.0) 312 | logging.info("[BACKEND] Forget the tag ID from the RFID reader.") 313 | 314 | if last_inside_raw == 1 and motion_inside_raw == 0: # Inside motion stopped (raw) 315 | last_motion_inside_raw_tm = motion_inside_raw_tm 316 | logging.debug(f"[BACKEND] Motion stopped INSIDE (raw)") 317 | 318 | if last_inside == 1 and motion_inside == 0: # Inside motion stopped 319 | last_motion_inside_tm = motion_inside_tm 320 | logging.info(f"[BACKEND] Motion stopped INSIDE") 321 | 322 | if last_outside == 0 and motion_outside == 1: # Outside motion detected 323 | motion_block_id += 1 324 | # If we use the camera for motion detection, set the first motion timestamp a bit earlier to avoid missing the first motion 325 | if use_camera_for_motion: 326 | first_motion_outside_tm = tm.time() - 0.5 327 | additional_log_info = f"| configured cat detection threshold: {CONFIG['CAT_THRESHOLD']} " 328 | else: 329 | first_motion_outside_tm = tm.time() 330 | model_handler.resume() 331 | additional_log_info = "" 332 | logging.info(f"[BACKEND] {motion_source}-based motion detection: Motion detected OUTSIDE {additional_log_info}(Block ID: {motion_block_id})") 333 | known_rfid_tags = db_get_all_rfid_tags(CONFIG['KITTYHACK_DATABASE_PATH']) 334 | cat_rfid_name_dict = get_cat_name_rfid_dict(CONFIG['KITTYHACK_DATABASE_PATH']) 335 | # Reset the additional verdict infos 336 | additional_verdict_infos = [] 337 | 338 | if last_inside_raw == 0 and motion_inside_raw == 1: # Inside motion detected 339 | logging.debug("[BACKEND] Motion detected INSIDE (raw)") 340 | first_motion_inside_raw_tm = tm.time() 341 | 342 | if last_inside == 0 and motion_inside == 1: # Inside motion detected 343 | logging.info("[BACKEND] Motion detected INSIDE") 344 | first_motion_inside_tm = tm.time() 345 | if check_allowed_to_exit() == True: 346 | if magnets.get_inside_state() == True: 347 | logging.info("[BACKEND] Inside magnet is already unlocked. Only one magnet is allowed. --> Outside magnet will not be unlocked.") 348 | else: 349 | logging.info("[BACKEND] Allow cats to exit.") 350 | if magnets.check_queued("unlock_outside") == False: 351 | magnets.queue_command("unlock_outside") 352 | unlock_outside_tm = tm.time() 353 | else: 354 | logging.info("[BACKEND] No cats are allowed to exit. YOU SHALL NOT PASS!") 355 | 356 | # Turn off the RFID reader if no motion outside and inside 357 | if ( (motion_outside == 0) and (motion_inside == 0) and 358 | ((tm.time() - last_motion_outside_tm) > RFID_READER_OFF_DELAY) and 359 | ((tm.time() - last_motion_inside_tm) > RFID_READER_OFF_DELAY) ): 360 | if rfid.get_field(): 361 | logging.info(f"[BACKEND] No motion outside since {RFID_READER_OFF_DELAY} seconds after the last motion. Stopping RFID reader.") 362 | rfid.set_field(False) 363 | 364 | # Start the RFID thread with infinite read cycles, if it is not running and motion is detected outside or inside 365 | if motion_outside == 1 or motion_inside == 1: 366 | if rfid.get_field() == False and (tag_id == None or tag_id not in known_rfid_tags): 367 | rfid.set_field(True) 368 | logging.info(f"[BACKEND] Enabled RFID field.") 369 | 370 | # Close the magnet to the outside after the timeout 371 | if ( (motion_inside == 0) and 372 | (magnets.get_outside_state() == True) and 373 | ((tm.time() - last_motion_inside_tm) > OPEN_OUTSIDE_TIMEOUT) and 374 | (magnets.check_queued("lock_outside") == False) ): 375 | magnets.queue_command("lock_outside") 376 | 377 | # Check also for a cat via the camera, if the option is enabled and no RFID tag is detected 378 | if CONFIG['USE_CAMERA_FOR_CAT_DETECTION'] and tag_id_from_video is None and motion_outside == 1: 379 | imgs_with_cats = image_buffer.get_filtered_ids(first_motion_outside_tm, min_own_cat_probability=CONFIG['CAT_THRESHOLD']) 380 | if len(imgs_with_cats) > 0: 381 | # Find the element with the highest probability 382 | max_prob = 0.0 383 | detected_cat = "" 384 | for element in imgs_with_cats: 385 | img = image_buffer.get_by_id(element) 386 | for obj in getattr(img, 'detected_objects', []): 387 | obj_name = getattr(obj, 'object_name', '').lower() 388 | obj_probability = getattr(obj, 'probability', 0.0) 389 | if obj_name not in ["prey", "beute"] and obj_probability > max_prob: 390 | max_prob = obj_probability 391 | detected_cat = obj_name 392 | if detected_cat != "": 393 | logging.info(f"[BACKEND] Detected cat '{detected_cat}' by video stream with probability {max_prob:.2f} in image ID {element}") 394 | # Look for the cat name in the values of the dictionary 395 | matching_tag = next((rfid for rfid, name in cat_rfid_name_dict.items() if name.lower() == detected_cat), None) 396 | if matching_tag: 397 | tag_id_from_video = matching_tag 398 | logging.info(f"[BACKEND] Detected cat '{detected_cat}' matches RFID tag '{tag_id_from_video}'") 399 | 400 | # Check for a valid RFID tag 401 | if ( tag_id and 402 | tag_id != previous_tag_id and 403 | rfid.get_field() ): 404 | logging.info(f"[BACKEND] RFID tag detected: '{tag_id}'.") 405 | if tag_id in known_rfid_tags: 406 | rfid.set_field(False) 407 | logging.info(f"[BACKEND] Detected RFID tag {tag_id} matches a known tag. Disabled RFID field.") 408 | 409 | # Check if we are allowed to open the inside direction 410 | if motion_outside and not unlock_inside_decision_made: 411 | if CONFIG['ALLOWED_TO_ENTER'] == AllowedToEnter.KNOWN and ( (tag_id in known_rfid_tags) or (tag_id_from_video in known_rfid_tags) ): 412 | tag_id_valid = True 413 | unlock_inside_decision_made = True 414 | logging.info("[BACKEND] Detected RFID tag is in the database. Kitty is allowed to enter...") 415 | elif CONFIG['ALLOWED_TO_ENTER'] == AllowedToEnter.ALL_RFIDS and tag_id is not None: 416 | tag_id_valid = True 417 | unlock_inside_decision_made = True 418 | logging.info("[BACKEND] All RFID tags are allowed. Kitty is allowed to enter...") 419 | elif CONFIG['ALLOWED_TO_ENTER'] == AllowedToEnter.NONE: 420 | tag_id_valid = False 421 | unlock_inside_decision_made = True 422 | logging.info("[BACKEND] No cats are allowed to enter. The door stays closed.") 423 | elif CONFIG['ALLOWED_TO_ENTER'] == AllowedToEnter.ALL: 424 | tag_id_valid = True 425 | unlock_inside_decision_made = True 426 | logging.info("[BACKEND] All cats are allowed to enter. Kitty is allowed to enter...") 427 | 428 | # Forget the tag after the tag timeout and no motion outside: 429 | if ( (tag_id is not None) and 430 | (tm.time() > (tag_timestamp+TAG_TIMEOUT)) and 431 | (motion_outside == 0) ): 432 | rfid.set_tag(None, 0.0) 433 | logging.info("[BACKEND] Tag timeout reached. Forget the tag.") 434 | 435 | if image_buffer.size() > 0 and first_motion_outside_tm > 0.0: 436 | # Process all elements in the buffer 437 | ids_of_current_motion_block = image_buffer.get_filtered_ids(min_timestamp=first_motion_outside_tm) 438 | ids_with_mouse = image_buffer.get_filtered_ids(min_timestamp=first_motion_outside_tm, min_mouse_probability=CONFIG['MOUSE_THRESHOLD']) 439 | else: 440 | ids_of_current_motion_block = [] 441 | ids_with_mouse = [] 442 | 443 | # Check if the inside magnet should be unlocked 444 | mouse_check_conditions = { 445 | "mouse_check_disabled": CONFIG['MOUSE_CHECK_ENABLED'] == False, 446 | "no_mouse_detected": len(ids_with_mouse) == 0, 447 | "sufficient_pictures": len(ids_of_current_motion_block) >= CONFIG['MIN_PICTURES_TO_ANALYZE'] 448 | } 449 | 450 | mouse_check = mouse_check_conditions["mouse_check_disabled"] or (mouse_check_conditions["no_mouse_detected"] and mouse_check_conditions["sufficient_pictures"]) 451 | 452 | unlock_inside_conditions = { 453 | "motion_outside": motion_outside == 1, 454 | "tag_id_valid": tag_id_valid, 455 | "inside_locked": magnets.get_inside_state() == False, 456 | "mouse_check": mouse_check, 457 | "outside_locked": magnets.get_outside_state() == False, 458 | "no_unlock_queued": magnets.check_queued("unlock_inside") == False, 459 | "no_prey_within_timeout": (tm.time() - backend_main.prey_detection_tm) > CONFIG['LOCK_DURATION_AFTER_PREY_DETECTION'] 460 | } 461 | 462 | if not hasattr(backend_main, "previous_mouse_check_conditions"): 463 | backend_main.previous_mouse_check_conditions = mouse_check_conditions 464 | else: 465 | for key, value in mouse_check_conditions.items(): 466 | if backend_main.previous_mouse_check_conditions[key] != value: 467 | logging.info(f"[BACKEND] Mouse check condition '{key}' changed to {value}.") 468 | 469 | # If the prey detection is enabled, check if this is the first iteration with detected prey 470 | if mouse_check == False and mouse_check_conditions["no_mouse_detected"] == False and backend_main.previous_mouse_check_conditions["no_mouse_detected"] == True: 471 | backend_main.prey_detection_tm = tm.time() 472 | logging.info(f"[BACKEND] Detected prey in the images. Set the timestamp for prey detection to {backend_main.prey_detection_tm}.") 473 | 474 | if backend_main.previous_mouse_check_conditions != mouse_check_conditions: 475 | logging.info(f"[BACKEND] Mouse check conditions: {mouse_check_conditions}") 476 | backend_main.previous_mouse_check_conditions = mouse_check_conditions 477 | 478 | if not hasattr(backend_main, "previous_unlock_inside_conditions"): 479 | backend_main.previous_unlock_inside_conditions = unlock_inside_conditions 480 | else: 481 | for key, value in unlock_inside_conditions.items(): 482 | if backend_main.previous_unlock_inside_conditions[key] != value: 483 | logging.info(f"[BACKEND] Unlock inside Condition '{key}' changed to {value}. ({sum(unlock_inside_conditions.values())}/{len(unlock_inside_conditions)} conditions fulfilled)") 484 | if backend_main.previous_unlock_inside_conditions != unlock_inside_conditions: 485 | logging.info(f"[BACKEND] Unlock inside conditions: {unlock_inside_conditions}") 486 | backend_main.previous_unlock_inside_conditions = unlock_inside_conditions 487 | 488 | unlock_inside = all(unlock_inside_conditions.values()) 489 | 490 | # Lock the inside if there was a mouse detected after the door was already unlocked 491 | if (mouse_check == False and magnets.get_inside_state() and magnets.check_queued("lock_inside") == False and inside_manually_unlocked == False): 492 | magnets.queue_command("lock_inside") 493 | unlock_inside_tm = 0.0 494 | 495 | if unlock_inside or manual_door_override['unlock_inside']: 496 | logging.info(f"[BACKEND] Door unlock requested {'(manual override)' if manual_door_override['unlock_inside'] else ''}") 497 | logging.debug(f"[BACKEND] Motion outside: {motion_outside}, Motion inside: {motion_inside}, Tag ID: {tag_id}, Tag valid: {tag_id_valid}, Motion block ID: {motion_block_id}, Images with mouse: {len(ids_with_mouse)}, Images in current block: {len(ids_of_current_motion_block)} ({ids_of_current_motion_block})") 498 | if manual_door_override['unlock_inside'] and magnets.get_inside_state(): 499 | logging.info("[BACKEND] Manual override: Inside door is already open.") 500 | else: 501 | magnets.empty_queue() 502 | magnets.queue_command("unlock_inside") 503 | unlock_inside_tm = tm.time() 504 | if manual_door_override['unlock_inside']: 505 | inside_manually_unlocked = True 506 | additional_verdict_infos.append(str(EventType.MANUALLY_UNLOCKED)) 507 | else: 508 | inside_manually_unlocked = False 509 | 510 | manual_door_override['unlock_inside'] = False 511 | 512 | if manual_door_override['unlock_inside']: 513 | if magnets.get_outside_state(): 514 | logging.info("[BACKEND] Manual override: Outside door is already open.") 515 | else: 516 | logging.info("[BACKEND] Manual override: Opening outside door") 517 | magnets.empty_queue() 518 | magnets.queue_command("unlock_outside") 519 | unlock_outside_tm = tm.time() 520 | 521 | manual_door_override['unlock_inside'] = False 522 | 523 | if manual_door_override['lock_inside']: 524 | if magnets.get_inside_state(): 525 | logging.info("[BACKEND] Manual override: Locking inside door") 526 | magnets.empty_queue() 527 | else: 528 | logging.info("[BACKEND] Manual override: Inside door is already locked.") 529 | inside_manually_unlocked = False 530 | manual_door_override['lock_inside'] = False 531 | additional_verdict_infos.append(str(EventType.MANUALLY_LOCKED)) 532 | 533 | # Check if maximum unlock time is exceeded 534 | if magnets.get_inside_state() and (tm.time() - unlock_inside_tm > MAX_UNLOCK_TIME) and magnets.check_queued("lock_inside") == False: 535 | logging.warning("[BACKEND] Maximum unlock time exceeded for inside door. Forcing lock.") 536 | magnets.queue_command("lock_inside") 537 | if inside_manually_unlocked: 538 | inside_manually_unlocked = False 539 | additional_verdict_infos.append(str(EventType.MAX_UNLOCK_TIME_EXCEEDED)) 540 | 541 | if magnets.get_outside_state() and (tm.time() - unlock_outside_tm > MAX_UNLOCK_TIME) and magnets.check_queued("lock_outside") == False: 542 | logging.warning("[BACKEND] Maximum unlock time exceeded for outside door. Forcing lock.") 543 | magnets.queue_command("lock_outside") 544 | 545 | except Exception as e: 546 | logging.error(f"[BACKEND] Exception in backend occured: {e}") 547 | 548 | # RFID Cleanup on shutdown: 549 | rfid.stop_read(wait_for_stop=True) 550 | rfid.set_field(False) 551 | rfid.set_power(False) 552 | 553 | logging.info("[BACKEND] Stopped backend.") 554 | sigterm_monitor.signal_task_done() -------------------------------------------------------------------------------- /src/baseconfig.py: -------------------------------------------------------------------------------- 1 | import os 2 | import gettext 3 | import configparser 4 | import logging 5 | from logging.handlers import RotatingFileHandler 6 | from datetime import datetime 7 | from zoneinfo import ZoneInfo 8 | from enum import Enum 9 | import uuid 10 | import json 11 | from configupdater import ConfigUpdater 12 | 13 | ###### ENUM DEFINITIONS ###### 14 | class AllowedToEnter(Enum): 15 | ALL = 'all' 16 | ALL_RFIDS = 'all_rfids' 17 | KNOWN = 'known' 18 | NONE = 'none' 19 | 20 | ###### CONSTANT DEFINITIONS ###### 21 | 22 | # Files 23 | CONFIGFILE = 'config.ini' 24 | LOGFILE = "kittyhack.log" 25 | JOURNAL_LOG = "/tmp/kittyhack-journal.log" 26 | 27 | # Gettext constants 28 | LOCALE_DIR = "locales" 29 | DOMAIN = "messages" 30 | 31 | # Global dictionary to store configuration settings 32 | CONFIG = {} 33 | 34 | # Default configuration values 35 | DEFAULT_CONFIG = { 36 | "Settings": { 37 | "timezone": "Europe/Berlin", 38 | "language": "en", 39 | "date_format": "yyyy-mm-dd", 40 | "database_path": "../kittyflap.db", 41 | "kittyhack_database_path": "./kittyhack.db", 42 | "max_photos_count": 6000, 43 | "simulate_kittyflap": False, 44 | "mouse_threshold": 70.0, 45 | "no_mouse_threshold": 70.0, 46 | "min_threshold": 30.0, 47 | "elements_per_page": 20, 48 | "loglevel": "INFO", 49 | "periodic_jobs_interval": 900, 50 | "allowed_to_enter": "all", 51 | "mouse_check_enabled": True, 52 | "min_pictures_to_analyze": 5, 53 | "show_images_with_overlay": True, 54 | "live_view_refresh_interval": 5.0, 55 | "kittyflap_config_migrated": False, 56 | "allowed_to_exit": True, 57 | "last_vacuum_date": "", 58 | "periodic_version_check": True, 59 | "kittyflap_db_nagscreen": False, 60 | "last_db_backup_date": "", 61 | "kittyhack_database_backup_path": "../kittyhack_backup.db", 62 | "pir_outside_threshold": 0.5, 63 | "pir_inside_threshold": 3.0, 64 | "wlan_tx_power": 7, 65 | "group_pictures_to_events": True, 66 | "tflite_model_version": "original_kittyflap_model_v2", 67 | "lock_duration_after_prey_detection": 300, 68 | "last_read_changelogs": "v1.0.0", 69 | "max_pictures_per_event_with_rfid": 100, 70 | "max_pictures_per_event_without_rfid": 30, 71 | "use_all_cores_for_image_processing": False, 72 | "last_booted_version": "v1.5.1", # Parameter introduced in v1.5.1 73 | "allowed_to_exit_range1": False, 74 | "allowed_to_exit_range1_from": "00:00", 75 | "allowed_to_exit_range1_to": "23:59", 76 | "allowed_to_exit_range2": False, 77 | "allowed_to_exit_range2_from": "00:00", 78 | "allowed_to_exit_range2_to": "23:59", 79 | "allowed_to_exit_range3": False, 80 | "allowed_to_exit_range3_from": "00:00", 81 | "allowed_to_exit_range3_to": "23:59", 82 | "labelstudio_version": None, 83 | "email": "", 84 | "user_name": "", 85 | "model_training": "", 86 | "yolo_model": "", 87 | "startup_shutdown_flag": False, 88 | "not_graceful_shutdowns": 0, 89 | "use_camera_for_cat_detection": False, 90 | "cat_threshold": 70.0, 91 | "use_camera_for_motion_detection": False, 92 | "camera_source": "internal", # can be "internal" or "ip_camera" 93 | "ip_camera_url": "" 94 | } 95 | } 96 | 97 | def load_config(): 98 | """ 99 | Loads the configuration file and populates the CONFIG dictionary. 100 | """ 101 | global CONFIG 102 | if not os.path.exists(CONFIGFILE): 103 | print(f"Configuration file '{CONFIGFILE}' not found. Creating with default values...") 104 | create_default_config() 105 | 106 | parser = configparser.ConfigParser() 107 | parser.read(CONFIGFILE) 108 | 109 | CONFIG = { 110 | "TIMEZONE": parser.get('Settings', 'timezone', fallback=DEFAULT_CONFIG['Settings']['timezone']), 111 | "LANGUAGE": parser.get('Settings', 'language', fallback=DEFAULT_CONFIG['Settings']['language']), 112 | "DATE_FORMAT": parser.get('Settings', 'date_format', fallback=DEFAULT_CONFIG['Settings']['date_format']), 113 | "DATABASE_PATH": parser.get('Settings', 'database_path', fallback=DEFAULT_CONFIG['Settings']['database_path']), 114 | "KITTYHACK_DATABASE_PATH": parser.get('Settings', 'kittyhack_database_path', fallback=DEFAULT_CONFIG['Settings']['kittyhack_database_path']), 115 | "MAX_PHOTOS_COUNT": parser.getint('Settings', 'max_photos_count', fallback=DEFAULT_CONFIG['Settings']['max_photos_count']), 116 | "SIMULATE_KITTYFLAP": parser.getboolean('Settings', 'simulate_kittyflap', fallback=DEFAULT_CONFIG['Settings']['simulate_kittyflap']), 117 | "MOUSE_THRESHOLD": parser.getfloat('Settings', 'mouse_threshold', fallback=DEFAULT_CONFIG['Settings']['mouse_threshold']), # Currently not used 118 | "NO_MOUSE_THRESHOLD": parser.getfloat('Settings', 'no_mouse_threshold', fallback=DEFAULT_CONFIG['Settings']['no_mouse_threshold']), 119 | "MIN_THRESHOLD": parser.getfloat('Settings', 'min_threshold', fallback=DEFAULT_CONFIG['Settings']['min_threshold']), 120 | "ELEMENTS_PER_PAGE": parser.getint('Settings', 'elements_per_page', fallback=DEFAULT_CONFIG['Settings']['elements_per_page']), 121 | "LOGLEVEL": parser.get('Settings', 'loglevel', fallback=DEFAULT_CONFIG['Settings']['loglevel']), 122 | "PERIODIC_JOBS_INTERVAL": parser.getint('Settings', 'periodic_jobs_interval', fallback=DEFAULT_CONFIG['Settings']['periodic_jobs_interval']), 123 | "ALLOWED_TO_ENTER": AllowedToEnter(parser.get('Settings', 'allowed_to_enter', fallback=DEFAULT_CONFIG['Settings']['allowed_to_enter'])), 124 | "MOUSE_CHECK_ENABLED": parser.getboolean('Settings', 'mouse_check_enabled', fallback=DEFAULT_CONFIG['Settings']['mouse_check_enabled']), 125 | "MIN_PICTURES_TO_ANALYZE": parser.getint('Settings', 'min_pictures_to_analyze', fallback=DEFAULT_CONFIG['Settings']['min_pictures_to_analyze']), 126 | "SHOW_IMAGES_WITH_OVERLAY": parser.getboolean('Settings', 'show_images_with_overlay', fallback=DEFAULT_CONFIG['Settings']['show_images_with_overlay']), 127 | "LIVE_VIEW_REFRESH_INTERVAL": parser.getfloat('Settings', 'live_view_refresh_interval', fallback=DEFAULT_CONFIG['Settings']['live_view_refresh_interval']), 128 | "KITTYFLAP_CONFIG_MIGRATED": parser.getboolean('Settings', 'kittyflap_config_migrated', fallback=DEFAULT_CONFIG['Settings']['kittyflap_config_migrated']), 129 | "ALLOWED_TO_EXIT": parser.getboolean('Settings', 'allowed_to_exit', fallback=DEFAULT_CONFIG['Settings']['allowed_to_exit']), 130 | "LAST_VACUUM_DATE": parser.get('Settings', 'last_vacuum_date', fallback=DEFAULT_CONFIG['Settings']['last_vacuum_date']), 131 | "PERIODIC_VERSION_CHECK": parser.getboolean('Settings', 'periodic_version_check', fallback=DEFAULT_CONFIG['Settings']['periodic_version_check']), 132 | "KITTYFLAP_DB_NAGSCREEN": parser.getboolean('Settings', 'kittyflap_db_nagscreen', fallback=DEFAULT_CONFIG['Settings']['kittyflap_db_nagscreen']), 133 | "LATEST_VERSION": "unknown", # This value will not be written to the config file 134 | "LAST_DB_BACKUP_DATE": parser.get('Settings', 'last_db_backup_date', fallback=DEFAULT_CONFIG['Settings']['last_db_backup_date']), 135 | "KITTYHACK_DATABASE_BACKUP_PATH": parser.get('Settings', 'kittyhack_database_backup_path', fallback=DEFAULT_CONFIG['Settings']['kittyhack_database_backup_path']), 136 | "PIR_OUTSIDE_THRESHOLD": parser.getfloat('Settings', 'pir_outside_threshold', fallback=DEFAULT_CONFIG['Settings']['pir_outside_threshold']), 137 | "PIR_INSIDE_THRESHOLD": parser.getfloat('Settings', 'pir_inside_threshold', fallback=DEFAULT_CONFIG['Settings']['pir_inside_threshold']), 138 | "WLAN_TX_POWER": parser.getint('Settings', 'wlan_tx_power', fallback=DEFAULT_CONFIG['Settings']['wlan_tx_power']), 139 | "GROUP_PICTURES_TO_EVENTS": parser.getboolean('Settings', 'group_pictures_to_events', fallback=DEFAULT_CONFIG['Settings']['group_pictures_to_events']), 140 | "TFLITE_MODEL_VERSION": parser.get('Settings', 'tflite_model_version', fallback=DEFAULT_CONFIG['Settings']['tflite_model_version']), 141 | "LOCK_DURATION_AFTER_PREY_DETECTION": parser.getint('Settings', 'lock_duration_after_prey_detection', fallback=DEFAULT_CONFIG['Settings']['lock_duration_after_prey_detection']), 142 | "LAST_READ_CHANGELOGS": parser.get('Settings', 'last_read_changelogs', fallback=DEFAULT_CONFIG['Settings']['last_read_changelogs']), 143 | "MAX_PICTURES_PER_EVENT_WITH_RFID": parser.getint('Settings', 'max_pictures_per_event_with_rfid', fallback=DEFAULT_CONFIG['Settings']['max_pictures_per_event_with_rfid']), 144 | "MAX_PICTURES_PER_EVENT_WITHOUT_RFID": parser.getint('Settings', 'max_pictures_per_event_without_rfid', fallback=DEFAULT_CONFIG['Settings']['max_pictures_per_event_without_rfid']), 145 | "USE_ALL_CORES_FOR_IMAGE_PROCESSING": parser.getboolean('Settings', 'use_all_cores_for_image_processing', fallback=DEFAULT_CONFIG['Settings']['use_all_cores_for_image_processing']), 146 | "LAST_BOOTED_VERSION": parser.get('Settings', 'last_booted_version', fallback=DEFAULT_CONFIG['Settings']['last_booted_version']), 147 | "ALLOWED_TO_EXIT_RANGE1": parser.getboolean('Settings', 'allowed_to_exit_range1', fallback=DEFAULT_CONFIG['Settings']['allowed_to_exit_range1']), 148 | "ALLOWED_TO_EXIT_RANGE1_FROM": parser.get('Settings', 'allowed_to_exit_range1_from', fallback=DEFAULT_CONFIG['Settings']['allowed_to_exit_range1_from']), 149 | "ALLOWED_TO_EXIT_RANGE1_TO": parser.get('Settings', 'allowed_to_exit_range1_to', fallback=DEFAULT_CONFIG['Settings']['allowed_to_exit_range1_to']), 150 | "ALLOWED_TO_EXIT_RANGE2": parser.getboolean('Settings', 'allowed_to_exit_range2', fallback=DEFAULT_CONFIG['Settings']['allowed_to_exit_range2']), 151 | "ALLOWED_TO_EXIT_RANGE2_FROM": parser.get('Settings', 'allowed_to_exit_range2_from', fallback=DEFAULT_CONFIG['Settings']['allowed_to_exit_range2_from']), 152 | "ALLOWED_TO_EXIT_RANGE2_TO": parser.get('Settings', 'allowed_to_exit_range2_to', fallback=DEFAULT_CONFIG['Settings']['allowed_to_exit_range2_to']), 153 | "ALLOWED_TO_EXIT_RANGE3": parser.getboolean('Settings', 'allowed_to_exit_range3', fallback=DEFAULT_CONFIG['Settings']['allowed_to_exit_range3']), 154 | "ALLOWED_TO_EXIT_RANGE3_FROM": parser.get('Settings', 'allowed_to_exit_range3_from', fallback=DEFAULT_CONFIG['Settings']['allowed_to_exit_range3_from']), 155 | "ALLOWED_TO_EXIT_RANGE3_TO": parser.get('Settings', 'allowed_to_exit_range3_to', fallback=DEFAULT_CONFIG['Settings']['allowed_to_exit_range3_to']), 156 | "LABELSTUDIO_VERSION": parser.get('Settings', 'labelstudio_version', fallback=DEFAULT_CONFIG['Settings']['labelstudio_version']), 157 | "EMAIL": parser.get('Settings', 'email', fallback=DEFAULT_CONFIG['Settings']['email']), 158 | "USER_NAME": parser.get('Settings', 'user_name', fallback=DEFAULT_CONFIG['Settings']['user_name']), 159 | "MODEL_TRAINING": parser.get('Settings', 'model_training', fallback=DEFAULT_CONFIG['Settings']['model_training']), 160 | "YOLO_MODEL": parser.get('Settings', 'yolo_model', fallback=DEFAULT_CONFIG['Settings']['yolo_model']), 161 | "STARTUP_SHUTDOWN_FLAG": parser.getboolean('Settings', 'startup_shutdown_flag', fallback=DEFAULT_CONFIG['Settings']['startup_shutdown_flag']), 162 | "NOT_GRACEFUL_SHUTDOWNS": parser.getint('Settings', 'not_graceful_shutdowns', fallback=DEFAULT_CONFIG['Settings']['not_graceful_shutdowns']), 163 | "USE_CAMERA_FOR_CAT_DETECTION": parser.getboolean('Settings', 'use_camera_for_cat_detection', fallback=DEFAULT_CONFIG['Settings']['use_camera_for_cat_detection']), 164 | "CAT_THRESHOLD": parser.getfloat('Settings', 'cat_threshold', fallback=DEFAULT_CONFIG['Settings']['cat_threshold']), 165 | "USE_CAMERA_FOR_MOTION_DETECTION": parser.getboolean('Settings', 'use_camera_for_motion_detection', fallback=DEFAULT_CONFIG['Settings'].get('use_camera_for_motion_detection', False)), 166 | "CAMERA_SOURCE": parser.get('Settings', 'camera_source', fallback=DEFAULT_CONFIG['Settings']['camera_source']), 167 | "IP_CAMERA_URL": parser.get('Settings', 'ip_camera_url', fallback=DEFAULT_CONFIG['Settings'].get('ip_camera_url', "")) 168 | } 169 | 170 | def save_config(): 171 | """ 172 | Saves the configuration file. 173 | Requires the CONFIG 174 | """ 175 | # prepare the updated values for the configfile 176 | updater = ConfigUpdater() 177 | updater.read(CONFIGFILE) 178 | 179 | settings = updater['Settings'] 180 | settings['timezone'] = CONFIG['TIMEZONE'] 181 | settings['language'] = CONFIG['LANGUAGE'] 182 | settings['date_format'] = CONFIG['DATE_FORMAT'] 183 | settings['database_path'] = CONFIG['DATABASE_PATH'] 184 | settings['kittyhack_database_path'] = CONFIG['KITTYHACK_DATABASE_PATH'] 185 | settings['max_photos_count'] = CONFIG['MAX_PHOTOS_COUNT'] 186 | settings['simulate_kittyflap'] = CONFIG['SIMULATE_KITTYFLAP'] 187 | settings['mouse_threshold'] = CONFIG['MOUSE_THRESHOLD'] 188 | settings['no_mouse_threshold'] = CONFIG['NO_MOUSE_THRESHOLD'] 189 | settings['min_threshold'] = CONFIG['MIN_THRESHOLD'] 190 | settings['elements_per_page'] = CONFIG['ELEMENTS_PER_PAGE'] 191 | settings['loglevel'] = CONFIG['LOGLEVEL'] 192 | settings['periodic_jobs_interval'] = CONFIG['PERIODIC_JOBS_INTERVAL'] 193 | settings['allowed_to_enter'] = CONFIG['ALLOWED_TO_ENTER'].value 194 | settings['mouse_check_enabled'] = str(CONFIG['MOUSE_CHECK_ENABLED']) 195 | settings['min_pictures_to_analyze'] = CONFIG['MIN_PICTURES_TO_ANALYZE'] 196 | settings['show_images_with_overlay'] = CONFIG['SHOW_IMAGES_WITH_OVERLAY'] 197 | settings['live_view_refresh_interval'] = CONFIG['LIVE_VIEW_REFRESH_INTERVAL'] 198 | settings['kittyflap_config_migrated'] = CONFIG['KITTYFLAP_CONFIG_MIGRATED'] 199 | settings['allowed_to_exit'] = CONFIG['ALLOWED_TO_EXIT'] 200 | settings['last_vacuum_date'] = CONFIG['LAST_VACUUM_DATE'] 201 | settings['periodic_version_check'] = CONFIG['PERIODIC_VERSION_CHECK'] 202 | settings['kittyflap_db_nagscreen'] = CONFIG['KITTYFLAP_DB_NAGSCREEN'] 203 | settings['last_db_backup_date'] = CONFIG['LAST_DB_BACKUP_DATE'] 204 | settings['kittyhack_database_backup_path'] = CONFIG['KITTYHACK_DATABASE_BACKUP_PATH'] 205 | settings['pir_outside_threshold'] = CONFIG['PIR_OUTSIDE_THRESHOLD'] 206 | settings['pir_inside_threshold'] = CONFIG['PIR_INSIDE_THRESHOLD'] 207 | settings['wlan_tx_power'] = CONFIG['WLAN_TX_POWER'] 208 | settings['group_pictures_to_events'] = CONFIG['GROUP_PICTURES_TO_EVENTS'] 209 | settings['tflite_model_version'] = CONFIG['TFLITE_MODEL_VERSION'] 210 | settings['lock_duration_after_prey_detection'] = CONFIG['LOCK_DURATION_AFTER_PREY_DETECTION'] 211 | settings['last_read_changelogs'] = CONFIG['LAST_READ_CHANGELOGS'] 212 | settings['max_pictures_per_event_with_rfid'] = CONFIG['MAX_PICTURES_PER_EVENT_WITH_RFID'] 213 | settings['max_pictures_per_event_without_rfid'] = CONFIG['MAX_PICTURES_PER_EVENT_WITHOUT_RFID'] 214 | settings['use_all_cores_for_image_processing'] = CONFIG['USE_ALL_CORES_FOR_IMAGE_PROCESSING'] 215 | settings['last_booted_version'] = CONFIG['LAST_BOOTED_VERSION'] 216 | settings['allowed_to_exit_range1'] = CONFIG['ALLOWED_TO_EXIT_RANGE1'] 217 | settings['allowed_to_exit_range1_from'] = CONFIG['ALLOWED_TO_EXIT_RANGE1_FROM'] 218 | settings['allowed_to_exit_range1_to'] = CONFIG['ALLOWED_TO_EXIT_RANGE1_TO'] 219 | settings['allowed_to_exit_range2'] = CONFIG['ALLOWED_TO_EXIT_RANGE2'] 220 | settings['allowed_to_exit_range2_from'] = CONFIG['ALLOWED_TO_EXIT_RANGE2_FROM'] 221 | settings['allowed_to_exit_range2_to'] = CONFIG['ALLOWED_TO_EXIT_RANGE2_TO'] 222 | settings['allowed_to_exit_range3'] = CONFIG['ALLOWED_TO_EXIT_RANGE3'] 223 | settings['allowed_to_exit_range3_from'] = CONFIG['ALLOWED_TO_EXIT_RANGE3_FROM'] 224 | settings['allowed_to_exit_range3_to'] = CONFIG['ALLOWED_TO_EXIT_RANGE3_TO'] 225 | #settings['labelstudio_version'] = CONFIG['LABELSTUDIO_VERSION'] # This value may not be written to the config file 226 | settings['email'] = CONFIG['EMAIL'] 227 | settings['user_name'] = CONFIG['USER_NAME'] 228 | settings['model_training'] = CONFIG['MODEL_TRAINING'] 229 | settings['yolo_model'] = CONFIG['YOLO_MODEL'] 230 | settings['startup_shutdown_flag'] = CONFIG['STARTUP_SHUTDOWN_FLAG'] 231 | settings['not_graceful_shutdowns'] = CONFIG['NOT_GRACEFUL_SHUTDOWNS'] 232 | settings['use_camera_for_cat_detection'] = CONFIG['USE_CAMERA_FOR_CAT_DETECTION'] 233 | settings['cat_threshold'] = CONFIG['CAT_THRESHOLD'] 234 | settings['use_camera_for_motion_detection'] = CONFIG['USE_CAMERA_FOR_MOTION_DETECTION'] 235 | settings['camera_source'] = CONFIG['CAMERA_SOURCE'] 236 | settings['ip_camera_url'] = CONFIG['IP_CAMERA_URL'] 237 | 238 | # Write updated configuration back to the file 239 | try: 240 | with open(CONFIGFILE, 'w') as configfile: 241 | updater.write(configfile) 242 | except: 243 | logging.error("Failed to update the values in the configfile.") 244 | return False 245 | 246 | logging.info("Updated the values in the configfile") 247 | return True 248 | 249 | def update_config_images_overlay(): 250 | """ 251 | Updates only the SHOW_IMAGES_WITH_OVERLAY setting in the configuration file. 252 | """ 253 | updater = ConfigUpdater() 254 | updater.read(CONFIGFILE) 255 | updater['Settings']['show_images_with_overlay'] = CONFIG['SHOW_IMAGES_WITH_OVERLAY'] 256 | 257 | # Write updated configuration back to the file 258 | try: 259 | with open(CONFIGFILE, 'w') as configfile: 260 | updater.write(configfile) 261 | logging.info("Updated SHOW_IMAGES_WITH_OVERLAY in the configfile") 262 | except Exception as e: 263 | logging.error(f"Failed to update SHOW_IMAGES_WITH_OVERLAY in the configfile: {e}") 264 | 265 | def update_single_config_parameter(parameter: str): 266 | """ 267 | Updates only a single config parameter in the configuration file. 268 | 269 | Args: 270 | parameter (str): The parameter name, which shall be updated. 271 | """ 272 | updater = ConfigUpdater() 273 | updater.read(CONFIGFILE) 274 | updater['Settings'][parameter.lower()] = CONFIG[parameter.upper()] 275 | 276 | # Write updated configuration back to the file 277 | try: 278 | with open(CONFIGFILE, 'w') as configfile: 279 | updater.write(configfile) 280 | logging.info(f"Updated {parameter.upper()} in the configfile to: {CONFIG[parameter.upper()]}") 281 | except Exception as e: 282 | logging.error(f"Failed to update {parameter.upper()} in the configfile: {e}") 283 | 284 | def create_default_config(): 285 | """ 286 | Creates the configuration file with default values. 287 | """ 288 | def stringify_dict(d): 289 | return {k: str(v) if v is not None else "" for k, v in d.items()} 290 | 291 | parser = configparser.ConfigParser() 292 | # Convert all values in DEFAULT_CONFIG to strings 293 | config_str = {section: stringify_dict(values) for section, values in DEFAULT_CONFIG.items()} 294 | parser.read_dict(config_str) 295 | with open(CONFIGFILE, 'w') as configfile: 296 | parser.write(configfile) 297 | logging.info(f"Default configuration written to {CONFIGFILE}") 298 | 299 | def set_language(language_code = "de"): 300 | """Load translations for the specified language.""" 301 | gettext.bindtextdomain(DOMAIN, LOCALE_DIR) 302 | gettext.textdomain(DOMAIN) 303 | lang = gettext.translation(DOMAIN, localedir=LOCALE_DIR, languages=[language_code], fallback=True) 304 | lang.install() 305 | return lang.gettext 306 | 307 | def configure_logging(level_name: str = "INFO"): 308 | """ 309 | Configures the logging settings. 310 | """ 311 | level = logging._nameToLevel.get(level_name.upper(), logging.INFO) 312 | 313 | # Remove all existing handlers from the root logger 314 | for h in logging.root.handlers[:]: 315 | logging.root.removeHandler(h) 316 | 317 | # Create a rotating file handler for logging 318 | # This handler will create log files with a maximum size of 10 MB each and keep up to 3 backup files 319 | handler = RotatingFileHandler(LOGFILE, maxBytes=10*1024*1024, backupCount=3) 320 | 321 | # Define the format for log messages 322 | formatter = TimeZoneFormatter('%(asctime)s [%(levelname)s] %(message)s') 323 | handler.setFormatter(formatter) 324 | 325 | # Get the root logger and set its level and handler 326 | logger = logging.getLogger() 327 | logger.setLevel(level) 328 | logger.addHandler(handler) 329 | logging.info(f"Logger loglevel set to {level_name.upper()}") 330 | 331 | # Custom formatter with timezone-aware local time 332 | class TimeZoneFormatter(logging.Formatter): 333 | def formatTime(self, record, datefmt=None): 334 | # Get current time in local timezone 335 | local_time = datetime.fromtimestamp(record.created, tz=ZoneInfo(CONFIG['TIMEZONE'])) 336 | 337 | # Build the timestamp with milliseconds and timezone offset 338 | timestamp = local_time.strftime('%Y-%m-%d %H:%M:%S') 339 | milliseconds = f"{local_time.microsecond // 1000:03d}" 340 | timezone = local_time.strftime('%z (%Z)') 341 | 342 | return f"{timestamp}.{milliseconds} {timezone}" 343 | 344 | class UserNotifications: 345 | """ 346 | Class to handle user notifications. 347 | The notifications are stored in a json file and will be displayed to the user when he opens the web interface. 348 | """ 349 | notifications = [] 350 | 351 | def __init__(cls): 352 | cls.load() 353 | 354 | @classmethod 355 | def load(cls): 356 | """ 357 | Load notifications from the json file. 358 | """ 359 | try: 360 | with open("notifications.json", "r") as f: 361 | cls.notifications = json.load(f) 362 | except FileNotFoundError: 363 | cls.notifications = [] 364 | except json.JSONDecodeError: 365 | logging.error("[USR_NOTIFICATIONS] Failed to decode notifications.json. Starting with an empty list.") 366 | cls.notifications = [] 367 | 368 | @classmethod 369 | def save(cls): 370 | """ 371 | Save notifications to the json file. 372 | """ 373 | with open("notifications.json", "w") as f: 374 | json.dump(cls.notifications, f, indent=4) 375 | 376 | @classmethod 377 | def add(cls, header, message, type="default", id=None, skip_if_id_exists=False): 378 | """ 379 | Add a notification to the list. 380 | Args: 381 | header (str): The header of the notification. 382 | message (str): The message of the notification. 383 | type (str): The type of the notification. Can be "default", "message", "warning", "error" 384 | id (str): The id of the notification. If None, a random id will be generated. 385 | skip_if_id_exists (bool): If True, skip adding the notification if the id already exists. 386 | """ 387 | if id is None: 388 | id = str(uuid.uuid4()) 389 | if skip_if_id_exists and any(n['id'] == id for n in cls.notifications): 390 | return 391 | cls.notifications.append({ 392 | "id": id, 393 | "header": header, 394 | "message": message, 395 | "type": type 396 | }) 397 | cls.save() 398 | logging.info(f"[USR_NOTIFICATIONS] Added notification: {header} - {message} (type: {type})") 399 | return id 400 | 401 | @classmethod 402 | def remove(cls, id: str): 403 | """ 404 | Remove a notification from the list. 405 | Args: 406 | id (str): The id of the notification to remove. 407 | """ 408 | cls.notifications = [n for n in cls.notifications if n['id'] != id] 409 | cls.save() 410 | logging.info(f"[USR_NOTIFICATIONS] Removed notification with id: {id}") 411 | return True 412 | 413 | @classmethod 414 | def clear(cls): 415 | """ 416 | Clear all notifications. 417 | """ 418 | cls.notifications = [] 419 | cls.save() 420 | logging.info("[USR_NOTIFICATIONS] Cleared all notifications") 421 | return True 422 | 423 | @classmethod 424 | def get_all(cls): 425 | """ 426 | Get all notifications. 427 | Returns: 428 | list: A list of notifications. 429 | """ 430 | return cls.notifications 431 | 432 | @classmethod 433 | def get_by_id(cls, id: str): 434 | """ 435 | Get a notification by its id. 436 | Args: 437 | id (str): The id of the notification to get. 438 | Returns: 439 | dict: The notification with the given id. 440 | """ 441 | for n in cls.notifications: 442 | if n['id'] == id: 443 | return n 444 | return None 445 | 446 | # ------------------------------------------------------------------------------------------------- 447 | 448 | # Initial load of the configuration 449 | load_config() 450 | 451 | # Configure logging 452 | configure_logging(CONFIG['LOGLEVEL']) 453 | 454 | # Initialize user notifications 455 | UserNotifications() -------------------------------------------------------------------------------- /src/camera.py: -------------------------------------------------------------------------------- 1 | # This code is based and inspired from the great Tensorflow + CV2 examples from Evan Juras: 2 | # https://github.com/EdjeElectronics/TensorFlow-Lite-Object-Detection-on-Android-and-Raspberry-Pi/ 3 | 4 | 5 | # Import packages 6 | import cv2 7 | import numpy as np 8 | import subprocess 9 | import shlex 10 | import threading 11 | import logging 12 | import time as tm 13 | from typing import List, Optional 14 | class VideoStream: 15 | """Camera object that controls video streaming from the Picamera or an IP camera""" 16 | 17 | # Camera state constants 18 | STATE_INITIALIZING = "initializing" 19 | STATE_RUNNING = "running" 20 | STATE_ERROR = "error" 21 | STATE_STOPPED = "stopped" 22 | STATE_INTERNAL = "internal_camera" 23 | STATE_IP_CAMERA = "ip_camera" 24 | 25 | def __init__( 26 | self, 27 | resolution=(800, 600), 28 | framerate=10, 29 | jpeg_quality=75, 30 | tuning_file="/usr/share/libcamera/ipa/rpi/vc4/ov5647_noir.json", 31 | source="internal", # "internal" or "ip_camera" 32 | ip_camera_url: str = None 33 | ): 34 | self.resolution = resolution 35 | self.framerate = framerate 36 | self.jpeg_quality = jpeg_quality 37 | self.tuning_file = tuning_file # Path to the tuning file 38 | self.stopped = False 39 | self.frames = [] 40 | self.buffer_size = 30 41 | self.process = None 42 | self.lock = threading.Lock() 43 | self.source = source 44 | self.ip_camera_url = ip_camera_url 45 | self.cap = None # For IP camera 46 | self.thread = None 47 | self.camera_state = self.STATE_INITIALIZING # <-- Add this line 48 | 49 | def get_camera_state(self): 50 | """Return the current camera connection state.""" 51 | return self.camera_state 52 | 53 | def get_resolution(self): 54 | """Return the current camera resolution as (width, height).""" 55 | return self.resolution 56 | 57 | def set_buffer_size(self, new_size: int): 58 | """Dynamically set the buffer size and trim frames if necessary.""" 59 | if new_size < 1: 60 | raise ValueError("Buffer size must be at least 1") 61 | with self.lock: 62 | self.buffer_size = new_size 63 | if len(self.frames) > self.buffer_size: 64 | # Remove oldest frames to fit the new buffer size 65 | self.frames = self.frames[-self.buffer_size:] 66 | logging.info(f"[CAMERA] Buffer size set to {self.buffer_size}") 67 | 68 | 69 | def start(self): 70 | self.camera_state = self.STATE_INITIALIZING 71 | self.thread = threading.Thread(target=self.update, args=(), daemon=True) 72 | self.thread.start() 73 | return self 74 | 75 | def update(self): 76 | if self.source == "internal": 77 | self.camera_state = self.STATE_INTERNAL 78 | # Internal Raspberry Pi camera via libcamera-vid 79 | tuning_option = f"--tuning-file {self.tuning_file}" if self.tuning_file else "" 80 | command = ( 81 | f"/usr/bin/libcamera-vid -t 0 --inline --width {self.resolution[0]} " 82 | f"--height {self.resolution[1]} --framerate {self.framerate} " 83 | f"--codec mjpeg --quality {self.jpeg_quality} {tuning_option} -o -" 84 | ) 85 | logging.info(f"[CAMERA] Running command: {command}") 86 | 87 | self.process = subprocess.Popen( 88 | shlex.split(command), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=4096 * 10000 89 | ) 90 | logging.info(f"[CAMERA] Subprocess started: {self.process.pid}") 91 | buffer = b"" 92 | try: 93 | self.camera_state = self.STATE_RUNNING 94 | while not self.stopped: 95 | chunk = self.process.stdout.read(4096) 96 | if not chunk: 97 | logging.warning("[CAMERA] Stream ended unexpectedly") 98 | break 99 | buffer += chunk 100 | 101 | # Extract JPEG frames 102 | while b'\xff\xd8' in buffer and b'\xff\xd9' in buffer: 103 | start = buffer.find(b'\xff\xd8') # Start of JPEG 104 | end = buffer.find(b'\xff\xd9') + 2 # End of JPEG 105 | jpeg_data = buffer[start:end] 106 | buffer = buffer[end:] 107 | 108 | # Decode the JPEG frame 109 | frame = cv2.imdecode(np.frombuffer(jpeg_data, np.uint8), cv2.IMREAD_COLOR) 110 | if frame is not None: 111 | frame = cv2.rotate(frame, cv2.ROTATE_180) 112 | with self.lock: 113 | self.frames.append(frame) 114 | if len(self.frames) > self.buffer_size: 115 | self.frames.pop(0) # Remove oldest frame 116 | else: 117 | logging.error("[CAMERA] Failed to decode frame") 118 | except Exception as e: 119 | logging.error(f"[CAMERA] Internal camera error: {e}") 120 | self.camera_state = self.STATE_ERROR 121 | elif self.source == "ip_camera" and self.ip_camera_url: 122 | self.camera_state = self.STATE_IP_CAMERA 123 | retry_delay = 5 # seconds 124 | 125 | # Define maximum allowed resolutions for common aspect ratios 126 | MAX_PIXELS = 1280 * 720 # 921600 127 | MAX_RESOLUTIONS = [ 128 | ((16, 9), (1280, 720)), 129 | ((4, 3), (1024, 768)), 130 | ((5, 4), (960, 768)), 131 | ((3, 2), (1080, 720)), 132 | ((1, 1), (850, 850)), 133 | ] 134 | 135 | def get_max_resolution(width, height): 136 | # Find the closest aspect ratio and its max resolution 137 | aspect = width / height 138 | best_diff = float('inf') 139 | best_res = (1280, 720) # fallback 140 | for (ar_w, ar_h), (max_w, max_h) in MAX_RESOLUTIONS: 141 | ar = ar_w / ar_h 142 | diff = abs(aspect - ar) 143 | if diff < best_diff: 144 | best_diff = diff 145 | best_res = (max_w, max_h) 146 | return best_res 147 | 148 | while not self.stopped: 149 | logging.info(f"[CAMERA] Connecting to IP camera at {self.ip_camera_url}") 150 | self.camera_state = self.STATE_INITIALIZING 151 | self.cap = cv2.VideoCapture(self.ip_camera_url) 152 | if not self.cap.isOpened(): 153 | logging.error(f"[CAMERA] Failed to open IP camera stream: {self.ip_camera_url}. Retrying in {retry_delay}s...") 154 | self.camera_state = self.STATE_ERROR 155 | self.cap.release() 156 | if self.stopped: 157 | break 158 | tm.sleep(retry_delay) 159 | continue 160 | 161 | # Get the actual resolution of the IP camera 162 | width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) 163 | height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) 164 | logging.info(f"[CAMERA] IP camera resolution: {width}x{height}") 165 | self.resolution = (width, height) # Update to actual resolution 166 | 167 | if width * height > MAX_PIXELS: 168 | logging.warning(f"[CAMERA] IP camera resolution {width}x{height} exceeds maximum allowed {MAX_PIXELS} pixels. The performance may be affected!") 169 | 170 | max_w, max_h = get_max_resolution(width, height) 171 | 172 | # Set desired resolution before reading frames 173 | self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, max_w) 174 | self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, max_h) 175 | 176 | self.camera_state = self.STATE_RUNNING 177 | while not self.stopped: 178 | ret, frame = self.cap.read() 179 | if not ret: 180 | logging.warning("[CAMERA] Failed to read frame from IP camera. Attempting to reconnect...") 181 | self.camera_state = self.STATE_ERROR 182 | break # Break inner loop to reconnect 183 | 184 | # DO NOT RESIZE HERE, let the IP camera handle it. Resizing is too CPU intensive. 185 | # Resize if frame exceeds allowed size, keeping aspect ratio 186 | #h, w = frame.shape[:2] 187 | #if w * h > MAX_PIXELS or w > max_w or h > max_h: 188 | # scale = min(max_w / w, max_h / h) 189 | # new_w, new_h = int(w * scale), int(h * scale) 190 | # frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA) 191 | 192 | with self.lock: 193 | self.frames.append(frame) 194 | if len(self.frames) > self.buffer_size: 195 | self.frames.pop(0) 196 | self.cap.release() 197 | if self.stopped: 198 | break 199 | logging.info(f"[CAMERA] Reconnecting to IP camera in {retry_delay}s...") 200 | self.camera_state = self.STATE_INITIALIZING 201 | tm.sleep(retry_delay) 202 | else: 203 | logging.error("[CAMERA] Invalid source or missing IP camera URL") 204 | self.camera_state = self.STATE_ERROR 205 | 206 | if self.stopped: 207 | self.camera_state = self.STATE_STOPPED 208 | final_frame = np.zeros((self.resolution[1], self.resolution[0], 3), dtype=np.uint8) 209 | # Calculate text size 210 | text = "Stream Ended. Please restart the Kittyflap." 211 | font = cv2.FONT_HERSHEY_SIMPLEX 212 | font_scale = 1 213 | thickness = 2 214 | text_size = cv2.getTextSize(text, font, font_scale, thickness)[0] 215 | 216 | # Calculate text position 217 | text_x = (final_frame.shape[1] - text_size[0]) // 2 218 | text_y = (final_frame.shape[0] + text_size[1]) // 2 219 | 220 | # Draw background rectangle 221 | cv2.rectangle(final_frame, (text_x - 10, text_y - text_size[1] - 10), 222 | (text_x + text_size[0] + 10, text_y + 10), (128, 128, 128), cv2.FILLED) 223 | 224 | with self.lock: 225 | self.frames = [final_frame] 226 | with self.lock: 227 | self.frame = final_frame 228 | logging.info("[CAMERA] Added final frame to indicate stream ended.") 229 | 230 | def read(self): 231 | # Return the most recent frame 232 | with self.lock: 233 | return self.frames[-1] if self.frames else None 234 | 235 | def read_oldest(self): 236 | # Return and remove the oldest frame from the list, but keep the last frame 237 | with self.lock: 238 | if len(self.frames) > 1: 239 | return self.frames.pop(0) 240 | elif len(self.frames) == 1: 241 | return self.frames[0] 242 | else: 243 | return None 244 | 245 | def stop(self): 246 | # Stop the video stream 247 | self.stopped = True 248 | if self.thread is not None: 249 | self.thread.join(timeout=5) # Wait for the thread to finish 250 | if self.source == "internal" and self.process: 251 | self.process.terminate() 252 | logging.info("[CAMERA] Video stream stopped.") 253 | elif self.source == "ip_camera" and self.cap: 254 | self.cap.release() 255 | logging.info("[CAMERA] IP camera stream stopped.") 256 | else: 257 | logging.error("[CAMERA] Video stream not yet started. Nothing to stop.") 258 | 259 | class DetectedObject: 260 | def __init__(self, x: float, y: float, width: float, height: float, object_name: str, probability: float): 261 | self.x = x # x as percentage of image width 262 | self.y = y # y as percentage of image height 263 | self.width = width # width as percentage of image width 264 | self.height = height # height as percentage of image height 265 | self.object_name = object_name 266 | self.probability = probability 267 | 268 | class ImageBufferElement: 269 | def __init__(self, id: int, block_id: int, timestamp: float, original_image: bytes | None, modified_image: bytes | None, 270 | mouse_probability: float, no_mouse_probability: float, own_cat_probability: float, tag_id: str = "", detected_objects: List[DetectedObject] = None): 271 | self.id = id 272 | self.block_id = block_id 273 | self.timestamp = timestamp 274 | self.original_image = original_image 275 | self.modified_image = modified_image 276 | self.mouse_probability = mouse_probability 277 | self.no_mouse_probability = no_mouse_probability 278 | self.own_cat_probability = own_cat_probability 279 | self.tag_id = tag_id 280 | self.detected_objects = detected_objects 281 | 282 | def __repr__(self): 283 | return (f"ImageBufferElement(id={self.id}, block_id={self.block_id}, timestamp={self.timestamp}, mouse_probability={self.mouse_probability}, " 284 | f"no_mouse_probability={self.no_mouse_probability}, own_cat_probability={self.own_cat_probability}, tag_id={self.tag_id}, detected_objects={self.detected_objects})") 285 | 286 | class ImageBuffer: 287 | MAX_IMAGE_BUFFER_SIZE = 1000 288 | 289 | def __init__(self): 290 | """Initialize an empty buffer.""" 291 | self._buffer: List[ImageBufferElement] = [] 292 | self._next_id = 0 293 | 294 | def append(self, timestamp: float, original_image: bytes | None, modified_image: bytes | None, 295 | mouse_probability: float, no_mouse_probability: float, own_cat_probability: float, detected_objects: List[DetectedObject] = None): 296 | """ 297 | Append a new element to the buffer. 298 | """ 299 | # --- Periodic logging for discarded elements --- 300 | if not hasattr(self, '_last_log_time'): 301 | self._last_log_time = timestamp 302 | self._appended_count = 0 303 | self._max_mouse_prob = 0.0 304 | self._max_no_mouse_prob = 0.0 305 | self._max_own_cat_prob = 0.0 306 | self._discarded_count = 0 307 | self._last_discard_log_time = timestamp 308 | 309 | if len(self._buffer) >= self.MAX_IMAGE_BUFFER_SIZE: 310 | self._buffer.pop(0) 311 | self._discarded_count += 1 312 | 313 | element = ImageBufferElement(self._next_id, 0, timestamp, original_image, modified_image, mouse_probability, no_mouse_probability, own_cat_probability, detected_objects=detected_objects) 314 | self._buffer.append(element) 315 | 316 | self._appended_count += 1 317 | self._max_mouse_prob = max(self._max_mouse_prob, mouse_probability) 318 | self._max_no_mouse_prob = max(self._max_no_mouse_prob, no_mouse_probability) 319 | self._max_own_cat_prob = max(self._max_own_cat_prob, own_cat_probability) 320 | 321 | # Periodic log for appended images and max probabilities 322 | if timestamp - self._last_log_time >= 60: 323 | logging.info( 324 | f"[IMAGEBUFFER] {self._appended_count} images appended in last 60s. " 325 | f"Max Mouse prob: {self._max_mouse_prob}, " 326 | f"Max No-mouse prob: {self._max_no_mouse_prob}, " 327 | f"Max Own-cat prob: {self._max_own_cat_prob}" 328 | ) 329 | self._last_log_time = timestamp 330 | self._appended_count = 0 331 | self._max_mouse_prob = 0.0 332 | self._max_no_mouse_prob = 0.0 333 | self._max_own_cat_prob = 0.0 334 | 335 | # Periodic log for discarded elements 336 | if timestamp - self._last_discard_log_time >= 60: 337 | if self._discarded_count > 0: 338 | logging.info( 339 | f"[IMAGEBUFFER] {self._discarded_count} oldest elements discarded from buffer in last 60s (buffer full)." 340 | ) 341 | self._discarded_count = 0 342 | self._last_discard_log_time = timestamp 343 | 344 | self._next_id += 1 345 | 346 | 347 | def pop(self) -> Optional[ImageBufferElement]: 348 | """ 349 | Return and remove the last element from the buffer. 350 | 351 | Returns: 352 | Optional[ImageBufferElement]: The last element if the buffer is not empty, else None. 353 | """ 354 | if self._buffer: 355 | logging.info(f"[IMAGEBUFFER] Popped element with ID {self._buffer[-1].id} from the buffer.") 356 | return self._buffer.pop() 357 | return None 358 | 359 | def clear(self): 360 | """Clear all elements in the buffer.""" 361 | self._buffer.clear() 362 | 363 | def size(self) -> int: 364 | """ 365 | Return the number of elements in the buffer. 366 | 367 | Returns: 368 | int: The number of elements in the buffer. 369 | """ 370 | return len(self._buffer) 371 | 372 | def get_all(self) -> List[ImageBufferElement]: 373 | """ 374 | Return all elements in the buffer. 375 | 376 | Returns: 377 | List[ImageBufferElement]: A list of all elements in the buffer. 378 | """ 379 | return self._buffer[:] 380 | 381 | def get_by_id(self, id: int) -> Optional[ImageBufferElement]: 382 | """ 383 | Return the element with the given ID. 384 | 385 | Args: 386 | id (int): The ID to search for. 387 | 388 | Returns: 389 | Optional[ImageBufferElement]: The element with the given ID if found, else None. 390 | """ 391 | for element in self._buffer: 392 | if element.id == id: 393 | return element 394 | return None 395 | 396 | def delete_by_id(self, id: int) -> bool: 397 | """ 398 | Delete the element with the given ID. 399 | 400 | Args: 401 | id (int): The ID to search for. 402 | 403 | Returns: 404 | bool: True if the element was deleted, else False. 405 | """ 406 | for i, element in enumerate(self._buffer): 407 | if element.id == id: 408 | self._buffer.pop(i) 409 | logging.info(f"[IMAGEBUFFER] Deleted element with ID {id} from the buffer.") 410 | return True 411 | logging.warning(f"[IMAGEBUFFER] Element with ID {id} not found in the buffer. Nothing was deleted.") 412 | return False 413 | 414 | def get_filtered_ids(self, min_timestamp=0.0, 415 | max_timestamp=float('inf'), 416 | min_mouse_probability=0.0, 417 | max_mouse_probability=100.0, 418 | min_no_mouse_probability=0.0, 419 | max_no_mouse_probability=100.0, 420 | min_own_cat_probability=0.0, 421 | max_own_cat_probability=100.0) -> List[int]: 422 | """ 423 | Return the IDs of elements that match the given filter criteria. 424 | 425 | Args: 426 | min_timestamp (float): The minimum timestamp. 427 | max_timestamp (float): The maximum timestamp. 428 | min_mouse_probability (float): The minimum mouse probability. 429 | max_mouse_probability (float): The maximum mouse probability. 430 | min_no_mouse_probability (float): The minimum no mouse probability. 431 | max_no_mouse_probability (float): The maximum no mouse probability. 432 | min_own_cat_probability (float): The minimum own cat probability. 433 | max_own_cat_probability (float): The maximum own cat probability. 434 | 435 | Returns: 436 | List[int]: A list of IDs that match the filter criteria. 437 | """ 438 | return [element.id for element in self._buffer if 439 | (min_timestamp <= element.timestamp <= max_timestamp) and 440 | (min_mouse_probability <= element.mouse_probability <= max_mouse_probability) and 441 | (min_no_mouse_probability <= element.no_mouse_probability <= max_no_mouse_probability) and 442 | (min_own_cat_probability <= element.own_cat_probability <= max_own_cat_probability)] 443 | 444 | def update_block_id(self, id: int, block_id: int) -> bool: 445 | """ 446 | Update the block ID of the element with the given ID. 447 | 448 | Args: 449 | id (int): The ID of the element to update. 450 | block_id (int): The new block ID. 451 | 452 | Returns: 453 | bool: True if the element was updated, else False. 454 | """ 455 | for element in self._buffer: 456 | if element.id == id: 457 | element.block_id = block_id 458 | return True 459 | return False 460 | 461 | def update_tag_id(self, id: int, tag_id: str) -> bool: 462 | """ 463 | Update the tag ID of the element with the given ID. 464 | 465 | Args: 466 | id (int): The ID of the element to update. 467 | tag_id (str): The new tag ID. 468 | 469 | Returns: 470 | bool: True if the element was updated, else False. 471 | """ 472 | for element in self._buffer: 473 | if element.id == id: 474 | element.tag_id = tag_id 475 | return True 476 | return False 477 | 478 | def get_by_block_id(self, block_id: int) -> List[ImageBufferElement]: 479 | """ 480 | Return all elements with the given block ID. 481 | 482 | Args: 483 | block_id (int): The block ID to search for. 484 | 485 | Returns: 486 | List[ImageBufferElement]: A list of elements with the given block ID. 487 | """ 488 | return [element for element in self._buffer if element.block_id == block_id] 489 | 490 | # Global variable declarations 491 | image_buffer = ImageBuffer() 492 | videostream = None -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | // Initialize the beforeinstallprompt listener at the very beginning - do not move this! 2 | let deferredPrompt = null; 3 | 4 | window.addEventListener('beforeinstallprompt', (e) => { 5 | deferredPrompt = e; 6 | const installContainer = document.getElementById('pwa_install_container'); 7 | if (installContainer) { 8 | installContainer.style.display = 'block'; 9 | } 10 | console.log('beforeinstallprompt event captured'); 11 | }); 12 | 13 | document.addEventListener("DOMContentLoaded", function() { 14 | // observe for the presence of the "allowed_to_exit_ranges" element 15 | let observer = new MutationObserver(function(mutations) { 16 | mutations.forEach(function(mutation) { 17 | if (document.getElementById("allowed_to_exit_ranges")) { 18 | toggleAllowedToExitRanges(); 19 | observer.disconnect(); // Stop observing once found 20 | } 21 | }); 22 | }); 23 | 24 | observer.observe(document.body, { childList: true, subtree: true }); 25 | 26 | function toggleAllowedToExitRanges() { 27 | let btnAllowedToExit = document.getElementById("btnAllowedToExit"); 28 | let allowedToExitRanges = document.getElementById("allowed_to_exit_ranges"); 29 | 30 | if (btnAllowedToExit && allowedToExitRanges) { 31 | allowedToExitRanges.style.display = btnAllowedToExit.checked ? "block" : "none"; 32 | btnAllowedToExit.addEventListener("change", function() { 33 | allowedToExitRanges.style.display = btnAllowedToExit.checked ? "block" : "none"; 34 | }); 35 | } 36 | } 37 | 38 | // --- Add functionality to reload on shiny-disconnected-overlay --- 39 | (function() { 40 | let reloadInterval = null; 41 | let reloadedOnce = false; 42 | let isNavigatingAway = false; 43 | 44 | // Listen for navigation attempts 45 | window.addEventListener('beforeunload', function() { 46 | isNavigatingAway = true; 47 | }); 48 | 49 | function checkForDisconnectOverlay() { 50 | const overlay = document.getElementById("shiny-disconnected-overlay"); 51 | 52 | if (overlay && !isNavigatingAway) { 53 | if (!reloadedOnce) { 54 | reloadedOnce = true; 55 | console.log("Detected disconnection overlay. Reloading..."); 56 | location.reload(); 57 | } 58 | 59 | if (!reloadInterval) { 60 | reloadInterval = setInterval(() => { 61 | const stillOverlay = document.getElementById("shiny-disconnected-overlay"); 62 | if (stillOverlay && !isNavigatingAway) { 63 | console.log("Still disconnected. Reloading again..."); 64 | location.reload(); 65 | } else { 66 | clearInterval(reloadInterval); 67 | reloadInterval = null; 68 | reloadedOnce = false; 69 | } 70 | }, 3000); 71 | } 72 | } 73 | } 74 | 75 | // Observe DOM changes for disconnection overlay 76 | const shinyObserver = new MutationObserver(checkForDisconnectOverlay); 77 | shinyObserver.observe(document.body, { childList: true, subtree: true }); 78 | 79 | // Also check immediately in case it's already present 80 | checkForDisconnectOverlay(); 81 | })(); 82 | 83 | // --- Collapse navbar on nav-link click (mobile fix) --- 84 | document.querySelectorAll('.navbar-collapse .nav-link').forEach(function (el) { 85 | el.addEventListener('click', function () { 86 | var navbarCollapse = el.closest('.navbar-collapse'); 87 | if (navbarCollapse && navbarCollapse.classList.contains('show')) { 88 | navbarCollapse.classList.remove('show'); 89 | } 90 | }); 91 | }); 92 | 93 | // --- Register Service Worker for PWA --- 94 | if('serviceWorker' in navigator) { 95 | navigator.serviceWorker 96 | .register('/pwa-service-worker.js', { scope: '/' }) 97 | .then(function(registration) { 98 | console.log('Service Worker Registered with scope:', registration.scope); 99 | }) 100 | .catch(function(error) { 101 | console.error('Service Worker registration failed:', error); 102 | }); 103 | } 104 | 105 | // --- PWA Installation functionality --- 106 | // Create observer to watch for PWA elements appearing in the DOM 107 | let pwaElementsObserver = new MutationObserver(function() { 108 | const installContainer = document.getElementById('pwa_install_container'); 109 | if (installContainer) { 110 | // Once the elements are found, initialize the PWA installation functionality 111 | initPwaInstallation(); 112 | // Stop observing once we've found the elements 113 | pwaElementsObserver.disconnect(); 114 | } 115 | }); 116 | 117 | // Start observing for PWA elements 118 | pwaElementsObserver.observe(document.body, { childList: true, subtree: true }); 119 | 120 | // Separate function to initialize PWA installation 121 | function initPwaInstallation() { 122 | 123 | // Get elements 124 | const installContainer = document.getElementById('pwa_install_container'); 125 | const installButton = document.getElementById('pwa_install_button'); 126 | const httpsWarning = document.getElementById('pwa_https_warning'); 127 | 128 | console.log("Install container found:", !!installContainer); 129 | console.log("Install button found:", !!installButton); 130 | 131 | if (installContainer) { 132 | // installContainer.style.display = 'none'; 133 | 134 | // Check if we're running on HTTPS 135 | if (window.location.protocol !== 'https:' && 136 | window.location.hostname !== 'localhost' && 137 | window.location.hostname !== '127.0.0.1') { 138 | // Show HTTPS warning 139 | if (httpsWarning) { 140 | httpsWarning.style.display = 'block'; 141 | } 142 | // Hide install button 143 | if (installButton) { 144 | installButton.style.display = 'none'; 145 | } 146 | console.warn("PWA installation is only available over HTTPS or localhost"); 147 | } 148 | 149 | // Check if app is already installed 150 | if (window.matchMedia('(display-mode: standalone)').matches || 151 | window.navigator.standalone === true) { 152 | // Show already installed message 153 | console.log("App appears to be already installed"); 154 | const alreadyInstalledMsg = document.getElementById('pwa_already_installed'); 155 | if (alreadyInstalledMsg) { 156 | alreadyInstalledMsg.style.display = 'block'; 157 | } 158 | if (installButton) { 159 | installButton.style.display = 'none'; 160 | } 161 | } 162 | 163 | console.log("Waiting for beforeinstallprompt event..."); 164 | } 165 | 166 | // Attach the click handler ONCE 167 | if (installButton) { 168 | installButton.addEventListener('click', async () => { 169 | if (!deferredPrompt) return; 170 | deferredPrompt.prompt(); 171 | const { outcome } = await deferredPrompt.userChoice; 172 | console.log(`User response to install prompt: ${outcome}`); 173 | deferredPrompt = null; 174 | if (outcome === 'accepted') { 175 | installButton.style.display = 'none'; 176 | const installedMsg = document.getElementById('pwa_installed_success'); 177 | if (installedMsg) { 178 | installedMsg.style.display = 'block'; 179 | } 180 | } 181 | }); 182 | } 183 | 184 | // Listen for the appinstalled event 185 | window.addEventListener('appinstalled', (evt) => { 186 | console.log('KITTYHACK was installed as PWA'); 187 | if (installButton) { 188 | installButton.style.display = 'none'; 189 | } 190 | const installedMsg = document.getElementById('pwa_installed_success'); 191 | if (installedMsg) { 192 | installedMsg.style.display = 'block'; 193 | } 194 | }); 195 | } 196 | 197 | // Check immediately in case elements are already present 198 | if (document.getElementById('pwa_install_container')) { 199 | initPwaInstallation(); 200 | } 201 | }); -------------------------------------------------------------------------------- /src/magnets_rfid.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import select 4 | import re 5 | from enum import Enum 6 | from src.system import Gpio, I2C 7 | from src.helper import sigterm_monitor 8 | import threading 9 | import string 10 | import time as tm 11 | from queue import Queue 12 | 13 | # MAGNETS: GPIO pin numbers and directions 14 | MAG_LOCK_TO_OUTSIDE_NUM = 524 15 | MAG_LOCK_TO_OUTSIDE_DIR = "out" 16 | MAG_LOCK_TO_INSIDE_NUM = 525 17 | MAG_LOCK_TO_INSIDE_DIR = "out" 18 | 19 | # RFID: GPIO pin numbers and directions 20 | RFID_FIELD_NUM = 529 21 | RFID_FIELD_DIR = "out" 22 | RFID_POWER_NUM = 515 23 | RFID_POWER_DIR = "out" 24 | 25 | I2CPORT=0 26 | PE_ADDR=0x20 27 | PE_DIRREG=0x03 28 | PE_OUTREG=0x01 29 | 30 | RFID_READ_PATH = "/dev/serial0" 31 | 32 | # Safety delay for the magnet command queue 33 | # WARNING: TOO LOW VALUES MAY DAMAGE THE HARDWARE! 34 | MAG_RFID_CMD_DELAY = 1.0 35 | 36 | # Create a Gpio instance 37 | gpio = Gpio() 38 | 39 | class HardwareCommandQueue: 40 | """ 41 | Shared command queue for hardware operations requiring safety delays. 42 | Used by both Magnets and RFID classes to ensure proper timing between operations. 43 | """ 44 | _instance = None 45 | 46 | def __new__(cls): 47 | if cls._instance is None: 48 | cls._instance = super(HardwareCommandQueue, cls).__new__(cls) 49 | cls._instance._initialized = False 50 | return cls._instance 51 | 52 | def __init__(self): 53 | if self._initialized: 54 | return 55 | 56 | self.command_queue = Queue() 57 | self.queue_lock = threading.Lock() 58 | self.last_command_time = tm.time() - MAG_RFID_CMD_DELAY # Allow immediate first command 59 | self.control_thread = None 60 | self._initialized = True 61 | 62 | def start_command_processor(self): 63 | """ 64 | Initializes and starts the command processing thread. 65 | """ 66 | if self.control_thread is None or not self.control_thread.is_alive(): 67 | self.control_thread = threading.Thread(target=self._process_commands) 68 | self.control_thread.daemon = True 69 | self.control_thread.start() 70 | logging.info("[HW_QUEUE] Started shared command queue processor thread.") 71 | 72 | def _process_commands(self): 73 | # Register task in the sigterm_monitor object 74 | sigterm_monitor.register_task() 75 | 76 | while not sigterm_monitor.stop_now: 77 | if not self.command_queue.empty(): 78 | current_time = tm.time() 79 | if current_time - self.last_command_time < MAG_RFID_CMD_DELAY: 80 | delay = MAG_RFID_CMD_DELAY - (current_time - self.last_command_time) 81 | logging.info(f"[HW_QUEUE] Waiting {delay:.1f} seconds before processing next command.") 82 | tm.sleep(delay) 83 | 84 | command = self.command_queue.get() 85 | cmd_type, func, args = command 86 | try: 87 | func(*args) 88 | logging.info(f"[HW_QUEUE] Executed {cmd_type} command.") 89 | except Exception as e: 90 | logging.error(f"[HW_QUEUE] Error executing {cmd_type} command: {e}") 91 | self.last_command_time = tm.time() 92 | tm.sleep(0.05) 93 | 94 | # When stop_now is detected, wait briefly for final commands to be enqueued 95 | logging.info("[HW_QUEUE] Shutdown detected. Waiting 3 seconds for final commands to be enqueued...") 96 | tm.sleep(3) 97 | 98 | # Process all remaining commands in the queue 99 | if not self.command_queue.empty(): 100 | cmd_count = self.command_queue.qsize() 101 | logging.info(f"[HW_QUEUE] Processing {cmd_count} remaining commands before shutdown...") 102 | 103 | while not self.command_queue.empty(): 104 | current_time = tm.time() 105 | if current_time - self.last_command_time < MAG_RFID_CMD_DELAY: 106 | delay = MAG_RFID_CMD_DELAY - (current_time - self.last_command_time) 107 | logging.info(f"[HW_QUEUE] Waiting {delay:.1f} seconds before processing shutdown command.") 108 | tm.sleep(delay) 109 | 110 | command = self.command_queue.get() 111 | cmd_type, func, args = command 112 | try: 113 | func(*args) 114 | logging.info(f"[HW_QUEUE] Executed {cmd_type} shutdown command.") 115 | except Exception as e: 116 | logging.error(f"[HW_QUEUE] Error executing {cmd_type} shutdown command: {e}") 117 | self.last_command_time = tm.time() 118 | 119 | logging.info("[HW_QUEUE] Stopped command queue thread.") 120 | sigterm_monitor.signal_task_done() 121 | 122 | def queue_command(self, cmd_type, func, *args): 123 | """ 124 | Adds a command to the command queue. 125 | 126 | Args: 127 | cmd_type (str): The type of command ('magnet' or 'rfid') 128 | func (callable): The function to execute 129 | args: Arguments to pass to the function 130 | """ 131 | if self.queue_lock.acquire(timeout=3): 132 | try: 133 | self.command_queue.put((cmd_type, func, args)) 134 | logging.info(f"[HW_QUEUE] {cmd_type.upper()} command added to queue.") 135 | finally: 136 | self.queue_lock.release() 137 | else: 138 | logging.error(f"[HW_QUEUE] Failed to acquire lock for adding {cmd_type} command to queue") 139 | 140 | def is_queue_empty(self): 141 | """ 142 | Checks if the command queue is empty. 143 | 144 | Returns: 145 | bool: True if the queue is empty, False otherwise. 146 | """ 147 | return self.command_queue.empty() 148 | 149 | def empty_queue(self): 150 | """ 151 | Empties the command queue. 152 | """ 153 | with self.queue_lock: 154 | while not self.command_queue.empty(): 155 | self.command_queue.get() 156 | logging.info("[HW_QUEUE] Command queue emptied.") 157 | 158 | class MagnetController: 159 | def __init__(self): 160 | self._magnet_state_outside = False # False = locked, True = unlocked 161 | self._magnet_state_inside = False # False = locked, True = unlocked 162 | 163 | @property 164 | def magnet_state_outside(self): 165 | return self._magnet_state_outside 166 | 167 | @magnet_state_outside.setter 168 | def magnet_state_outside(self, state): 169 | self._magnet_state_outside = state 170 | 171 | @property 172 | def magnet_state_inside(self): 173 | return self._magnet_state_inside 174 | 175 | @magnet_state_inside.setter 176 | def magnet_state_inside(self, state): 177 | self._magnet_state_inside = state 178 | 179 | class Magnets: 180 | instance = None 181 | 182 | def __init__(self, simulate_kittyflap=False): 183 | self.magnet_controller = MagnetController() 184 | self.simulate_kittyflap = simulate_kittyflap 185 | # Use the shared command queue 186 | self.command_queue = HardwareCommandQueue() 187 | 188 | def init(self): 189 | Magnets.instance = self 190 | 191 | if self.simulate_kittyflap: 192 | logging.info("[MAGNETS] Simulation mode enabled. Magnets would be now initialized.") 193 | else: 194 | try: 195 | # Configure GPIO pins for magnets 196 | gpio.configure(MAG_LOCK_TO_OUTSIDE_NUM, MAG_LOCK_TO_OUTSIDE_DIR) 197 | gpio.configure(MAG_LOCK_TO_INSIDE_NUM, MAG_LOCK_TO_INSIDE_DIR) 198 | 199 | # Start the command processor 200 | self.command_queue.start_command_processor() 201 | 202 | # Ensure both magnets are powered off 203 | self.queue_command("lock_inside") 204 | self.queue_command("lock_outside") 205 | except Exception as e: 206 | logging.error(f"[MAGNETS] Error initializing Magnets: {e}") 207 | else: 208 | logging.info("[MAGNETS] Magnets initialized and released.") 209 | 210 | def _unlock_inside(self): 211 | """ 212 | Unlocks the magnet lock to the inside direction. 213 | """ 214 | self.magnet_controller.magnet_state_inside = True 215 | 216 | if self.simulate_kittyflap: 217 | logging.info("[MAGNETS] Simulation: Inside direction would be now unlocked.") 218 | return 219 | 220 | try: 221 | gpio.set(MAG_LOCK_TO_INSIDE_NUM, 1) 222 | except Exception as e: 223 | logging.error(f"[MAGNETS] Error unlocking inside direction: {e}") 224 | else: 225 | logging.info("[MAGNETS] Inside direction is now unlocked.") 226 | 227 | def _lock_inside(self): 228 | """ 229 | Locks the magnet lock to the inside direction. 230 | """ 231 | self.magnet_controller.magnet_state_inside = False 232 | 233 | if self.simulate_kittyflap: 234 | logging.info("[MAGNETS] Simulation: Inside direction would be now locked.") 235 | return 236 | 237 | try: 238 | gpio.set(MAG_LOCK_TO_INSIDE_NUM, 0) 239 | except Exception as e: 240 | logging.error(f"[MAGNETS] Error locking inside direction: {e}") 241 | else: 242 | logging.info("[MAGNETS] Inside direction is now locked.") 243 | 244 | def _unlock_outside(self): 245 | """ 246 | Unlocks the magnet lock to the outside direction. 247 | """ 248 | self.magnet_controller.magnet_state_outside = True 249 | 250 | if self.simulate_kittyflap: 251 | logging.info("[MAGNETS] Simulation: Outside direction would be now unlocked.") 252 | return 253 | 254 | try: 255 | gpio.set(MAG_LOCK_TO_OUTSIDE_NUM, 1) 256 | except Exception as e: 257 | logging.error(f"[MAGNETS] Error unlocking outside direction: {e}") 258 | else: 259 | logging.info("[MAGNETS] Outside direction is now unlocked.") 260 | 261 | def _lock_outside(self): 262 | """ 263 | Locks the magnet lock to the outside direction. 264 | """ 265 | self.magnet_controller.magnet_state_outside = False 266 | 267 | if self.simulate_kittyflap: 268 | logging.info("[MAGNETS] Simulation: Outside direction would be now locked.") 269 | return 270 | 271 | try: 272 | gpio.set(MAG_LOCK_TO_OUTSIDE_NUM, 0) 273 | except Exception as e: 274 | logging.error(f"[MAGNETS] Error locking outside direction: {e}") 275 | else: 276 | logging.info("[MAGNETS] Outside direction is now locked.") 277 | 278 | def get_outside_state(self) -> bool: 279 | """ 280 | Returns the current state of the magnet lock to the outside direction. 281 | 282 | Returns: 283 | bool: True if the outside direction is unlocked, False if the outside direction is locked. 284 | """ 285 | return self.magnet_controller.magnet_state_outside 286 | 287 | def get_inside_state(self) -> bool: 288 | """ 289 | Returns the current state of the magnet lock to the inside direction. 290 | 291 | Returns: 292 | bool: True if the inside direction is unlocked, False if the inside direction is locked. 293 | """ 294 | return self.magnet_controller.magnet_state_inside 295 | 296 | def start_magnet_control(self): 297 | """ 298 | Initializes and starts the magnet control thread. 299 | """ 300 | # Start the shared command processor 301 | self.command_queue.start_command_processor() 302 | 303 | def queue_command(self, command): 304 | """ 305 | Adds a command to the command queue. 306 | 307 | Args: 308 | command (str): The command to be added to the queue: 309 | - "unlock_inside": Unlocks the magnet lock to the inside direction. 310 | - "lock_inside": Locks the magnet lock to the inside direction. 311 | - "unlock_outside": Unlocks the magnet lock to the outside direction. 312 | - "lock_outside": Locks the magnet lock to the outside direction. 313 | """ 314 | func_map = { 315 | "unlock_inside": self._unlock_inside, 316 | "lock_inside": self._lock_inside, 317 | "unlock_outside": self._unlock_outside, 318 | "lock_outside": self._lock_outside 319 | } 320 | 321 | if command in func_map: 322 | self.command_queue.queue_command("magnet", func_map[command]) 323 | logging.info(f"[MAGNETS] Command '{command}' added to shared queue.") 324 | else: 325 | logging.error(f"[MAGNETS] Unknown command: {command}") 326 | 327 | def check_queued(self, command): 328 | """ 329 | Checks whether a command is already in the command queue. 330 | 331 | Args: 332 | command (str): The command to be checked: 333 | - "unlock_inside": Unlocks the magnet lock to the inside direction. 334 | - "lock_inside": Locks the magnet lock to the inside direction. 335 | - "unlock_outside": Unlocks the magnet lock to the outside direction. 336 | - "lock_outside": Locks the magnet lock to the outside direction. 337 | 338 | Returns: 339 | bool: True if the command is already in the queue, False otherwise. 340 | """ 341 | func_map = { 342 | "unlock_inside": self._unlock_inside, 343 | "lock_inside": self._lock_inside, 344 | "unlock_outside": self._unlock_outside, 345 | "lock_outside": self._lock_outside 346 | } 347 | 348 | if command not in func_map: 349 | return False 350 | 351 | # Get the corresponding function for the command 352 | target_func = func_map[command] 353 | 354 | # Check the queue items 355 | with self.command_queue.queue_lock: 356 | queue_items = list(self.command_queue.command_queue.queue) 357 | for item in queue_items: 358 | # Each item is (cmd_type, func, args) 359 | if item[1] == target_func: 360 | return True 361 | 362 | return False 363 | 364 | def empty_queue(self, shutdown=False): 365 | """ 366 | Checks for remaining commands in the command queue and empties it to return magnets to idle state. 367 | """ 368 | try: 369 | # Empty the shared queue 370 | self.command_queue.empty_queue() 371 | 372 | if self.get_outside_state(): 373 | if shutdown: 374 | logging.info("[MAGNETS] Shutdown detected! Locking outside direction.") 375 | else: 376 | logging.info("[MAGNETS] Emptying queue! Locking outside direction.") 377 | # Directly call _lock_outside without queuing to ensure immediate action during shutdown 378 | self._lock_outside() 379 | 380 | if self.get_inside_state(): 381 | if shutdown: 382 | logging.info("[MAGNETS] Shutdown detected! Locking inside direction.") 383 | else: 384 | logging.info("[MAGNETS] Emptying queue! Locking inside direction.") 385 | # Directly call _lock_inside without queuing to ensure immediate action during shutdown 386 | self._lock_inside() 387 | except Exception as e: 388 | logging.error(f"[MAGNETS] Error emptying queue: {e}") 389 | 390 | class RfidRunState(Enum): 391 | stopped = 0 392 | running = 1 393 | stop_requested = 2 394 | 395 | class Rfid: 396 | def __init__(self, simulate_kittyflap=False): 397 | self.simulate_kittyflap = simulate_kittyflap 398 | self.tag_id = None 399 | self.timestamp = 0.0 400 | self.rfid_run_state = RfidRunState.stopped 401 | self.field_state = False 402 | self.thread_lock = threading.Lock() 403 | # Use the shared command queue 404 | self.command_queue = HardwareCommandQueue() 405 | self.init() 406 | 407 | def init(self): 408 | """ 409 | Enable RFID reader. 410 | """ 411 | if self.simulate_kittyflap: 412 | logging.info("[RFID] Simulation mode enabled. RFID is not powered on.") 413 | else: 414 | try: 415 | # Configure GPIO pins for RFID 416 | gpio.configure(RFID_POWER_NUM, RFID_POWER_DIR) 417 | gpio.configure(RFID_FIELD_NUM, RFID_FIELD_DIR) 418 | 419 | # Start the command processor if not already started 420 | self.command_queue.start_command_processor() 421 | 422 | tm.sleep(0.25) 423 | 424 | # PCA6408AHKX setup 425 | i2c = I2C() 426 | i2c.enable_gate(self) 427 | 428 | # Ensure RFID is powered off to avoid unnecessary interference 429 | self.set_power(False) 430 | self.set_field(False) 431 | tm.sleep(1.0) 432 | self.set_power(True) 433 | 434 | except Exception as e: 435 | logging.error(f"[RFID] Error initializing RFID: {e}") 436 | else: 437 | logging.info("[RFID] RFID initialized.") 438 | 439 | def set_power(self, state: bool): 440 | """ 441 | Sets the power state of the RFID module. 442 | 443 | Args: 444 | state (bool): Desired power state of the RFID module. 445 | True to enable power, False to disable power. 446 | """ 447 | if self.simulate_kittyflap: 448 | logging.info(f"[RFID] Simulation: RFID power would be {'enabled' if state else 'disabled'}.") 449 | return 450 | 451 | # Use the shared command queue for setting the RFID power 452 | self.command_queue.queue_command("rfid", self._set_power_hardware, state) 453 | logging.info(f"[RFID] RFID power {'enable' if state else 'disable'} command queued.") 454 | 455 | def _set_power_hardware(self, state: bool): 456 | """ 457 | Hardware implementation of setting the RFID power state. 458 | This method is called by the command queue processor. 459 | 460 | Args: 461 | state (bool): Desired state of the RFID power. True to enable, False to disable. 462 | """ 463 | try: 464 | gpio.set(RFID_POWER_NUM, 1 if state else 0) 465 | except Exception as e: 466 | logging.error(f"[RFID] Error setting RFID power: {e}") 467 | else: 468 | logging.info(f"[RFID] RFID power {'enabled' if state else 'disabled'}.") 469 | 470 | def set_field(self, state: bool): 471 | """ 472 | Sets the RFID field state by controlling the GPIO pin. 473 | 474 | Args: 475 | state (bool): Desired state of the RFID field. True to enable, False to disable. 476 | """ 477 | if self.simulate_kittyflap: 478 | logging.info(f"[RFID] Simulation: RFID field would be {'enabled' if state else 'disabled'}.") 479 | self.field_state = state 480 | return 481 | 482 | # Use the shared command queue for setting the RFID field 483 | self.command_queue.queue_command("rfid", self._set_field_hardware, state) 484 | # Update the field state immediately for status queries 485 | self.field_state = state 486 | logging.info(f"[RFID] RFID field {'enable' if state else 'disable'} command queued.") 487 | 488 | def _set_field_hardware(self, state: bool): 489 | """ 490 | Hardware implementation of setting the RFID field state. 491 | This method is called by the command queue processor. 492 | 493 | Args: 494 | state (bool): Desired state of the RFID field. True to enable, False to disable. 495 | """ 496 | try: 497 | gpio.set(RFID_FIELD_NUM, 1 if state else 0) 498 | except Exception as e: 499 | logging.error(f"[RFID] Error setting RFID field: {e}") 500 | else: 501 | logging.info(f"[RFID] RFID field {'enabled' if state else 'disabled'}.") 502 | 503 | def get_field(self): 504 | """ 505 | Returns the current state of the RFID field. 506 | """ 507 | return self.field_state 508 | 509 | def remove_non_printable_chars(self, line): 510 | # Remove all non-printable characters 511 | return ''.join(filter(lambda x: x in string.printable, line)) 512 | 513 | def run(self, read_cycles=0): 514 | """ 515 | Reads RFID tags either from a simulated environment or from a real RFID reader. 516 | 517 | Args: 518 | read_cycles (int): The number of read cycles to perform. If set to 0, the function will read indefinitely. 519 | 520 | Simulated Mode: 521 | If SIMULATE_KITTYFLAP is True, the function will simulate reading an RFID tag by generating a fixed tag ID 522 | and waiting for a random delay between 0.5 and 15.0 seconds between reads. 523 | 524 | Real Mode: 525 | If SIMULATE_KITTYFLAP is False, the function will read from the RFID reader specified by RFID_READ_PATH. 526 | It waits for a tag to be detected and reads the tag ID, removing any non-hexadecimal characters from the ID. 527 | The function logs the tag ID and the timestamp of each read operation. 528 | 529 | Raises: 530 | Exception: If an error occurs while reading from the RFID reader, it logs the error and returns None. 531 | """ 532 | if self.get_run_state() in [RfidRunState.running, RfidRunState.stop_requested]: 533 | logging.error("[RFID] Another RFID read operation is already running.") 534 | return 535 | else: 536 | logging.info(f"[RFID] Starting RFID read operation (read cycles: {read_cycles if read_cycles != 0 else '∞'})") 537 | 538 | try: 539 | self.set_power(True) 540 | self.set_run_state(RfidRunState.running) 541 | 542 | if self.simulate_kittyflap: 543 | tag_id = "BAADF00DBAADFEED" 544 | cycle = 0 545 | while read_cycles == 0 or cycle < read_cycles: 546 | delay = random.uniform(0.5, 15.0) 547 | tm.sleep(delay) 548 | logging.info(f"[RFID] Simulation: Tag ID: {tag_id} (read cycle {cycle+1}/{read_cycles if read_cycles != 0 else '∞'})") 549 | timestamp = tm.time() 550 | self.set_tag(tag_id, timestamp) 551 | cycle += 1 552 | if self.get_run_state() == RfidRunState.stop_requested: 553 | break 554 | return 555 | 556 | logging.info(f"[RFID] Waiting for RFID tag... (max {read_cycles if read_cycles != 0 else '∞'} cycles)") 557 | try: 558 | line = None 559 | duplicate_count = 0 560 | with open(RFID_READ_PATH, "rb") as f: 561 | cycle = 0 562 | while (read_cycles == 0 or cycle < read_cycles) and (self.get_run_state() != RfidRunState.stop_requested): 563 | #logging.debug(f"[RFID] Cycle {cycle+1}/{read_cycles if read_cycles != 0 else '∞'} | RFID run state: {self.get_run_state()}") 564 | ready, __, __ = select.select([f], [], [], 1.0) 565 | if ready: 566 | line = f.readline().decode('utf-8', errors='ignore').strip() 567 | line = self.remove_non_printable_chars(line) 568 | # Skip empty lines and initialization messages from the RFID reader 569 | match = re.search(r'([0-9A-Fa-f]{16})', line) 570 | if not match: 571 | logging.info(f"[RFID] Skipping line: '{line}' - No valid 16-char hex substring found") 572 | continue 573 | 574 | # We found a valid 16-char hex substring 575 | tag_id = match.group(1) 576 | timestamp = tm.time() 577 | last_tag, last_tm = self.get_tag() 578 | self.set_tag(tag_id, timestamp) 579 | 580 | if tag_id == last_tag: 581 | logging.debug(f"[RFID] Skipping duplicate tag: '{tag_id}'") 582 | duplicate_count += 1 583 | continue 584 | if duplicate_count > 0: 585 | logging.info(f"[RFID] Skipped {duplicate_count} previous duplicate tags of '{last_tag}'") 586 | duplicate_count = 0 587 | logging.info(f"[RFID] Tag ID: '{tag_id}' (raw Tag ID: '{line}') detected at {timestamp} (read cycle {cycle+1}/{read_cycles if read_cycles != 0 else '∞'})") 588 | 589 | #tm.sleep(0.1) 590 | cycle += 1 591 | except Exception as e: 592 | logging.error(f"[RFID] Error reading RFID: {e}") 593 | return 594 | if read_cycles != 0: 595 | logging.info(f"[RFID] Max read cycles reached. Ending RFID read.") 596 | return 597 | 598 | finally: 599 | # Power off the RFID field 600 | self.set_field(False) 601 | self.set_run_state(RfidRunState.stopped) 602 | logging.info("[RFID] RFID read operation stopped.") 603 | 604 | def stop_read(self, wait_for_stop=True): 605 | """ 606 | Stops the RFID read operation. 607 | """ 608 | self.set_run_state(RfidRunState.stop_requested) 609 | logging.info("[RFID] Requested stop of the RFID read operation.") 610 | 611 | if wait_for_stop: 612 | while True: 613 | if self.get_run_state() == RfidRunState.stopped: 614 | break 615 | tm.sleep(0.1) 616 | 617 | def time_delta_to_last_read(self): 618 | """ 619 | Returns the time delta in seconds to the last successful RFID read operation. 620 | """ 621 | with self.thread_lock: 622 | return tm.time() - self.timestamp 623 | 624 | def set_run_state(self, state: RfidRunState): 625 | """ 626 | Sets the RFID run state to the specified state. 627 | """ 628 | with self.thread_lock: 629 | if state == RfidRunState.stop_requested and self.rfid_run_state == RfidRunState.stopped: 630 | logging.warning("[RFID] RFID run state is already stopped. Ignoring stop request.") 631 | else: 632 | self.rfid_run_state = state 633 | 634 | def get_run_state(self): 635 | """ 636 | Returns the current RFID run state. 637 | """ 638 | with self.thread_lock: 639 | return self.rfid_run_state 640 | 641 | def get_tag(self): 642 | """ 643 | Thread-safe method to read the current tag id with the according timestamp. 644 | 645 | Returns: 646 | tuple: A tuple containing: tag_id, timestamp 647 | """ 648 | with self.thread_lock: 649 | return self.tag_id, self.timestamp 650 | 651 | def set_tag(self, tag_id, timestamp): 652 | """ 653 | Thread-safe method to set the current tag id with the according timestamp. 654 | 655 | Args: 656 | tag_id (str): The tag id to set. 657 | timestamp (float): The timestamp to set. 658 | """ 659 | with self.thread_lock: 660 | self.tag_id = tag_id 661 | self.timestamp = timestamp -------------------------------------------------------------------------------- /src/pir.py: -------------------------------------------------------------------------------- 1 | import os 2 | import threading 3 | import time as tm 4 | import logging 5 | import random 6 | from src.system import Gpio 7 | from src.helper import sigterm_monitor, CONFIG 8 | 9 | # GPIO pin numbers and directions 10 | OUTSIDE_PIR_GPIO_NUM = 536 11 | OUTSIDE_PIR_GPIO_DIR = "in" 12 | INSIDE_PIR_GPIO_NUM = 535 13 | INSIDE_PIR_GPIO_DIR = "in" 14 | OUTSIDE_POWER_GPIO_NUM = 517 15 | OUTSIDE_POWER_GPIO_DIR = "out" 16 | INSIDE_POWER_GPIO_NUM = 516 17 | INSIDE_POWER_GPIO_DIR = "out" 18 | 19 | PIR_READ_INTERVAL = 0.05 20 | 21 | # Create a Gpio instance 22 | gpio = Gpio() 23 | 24 | class Pir: 25 | instance = None 26 | 27 | def __init__(self, simulate_kittyflap=False): 28 | self.state_outside = 0 # 0 = no motion, 1 = motion detected 29 | self.state_inside = 0 # 0 = no motion, 1 = motion detected 30 | self.state_outside_raw = 0 31 | self.state_inside_raw = 0 32 | self.thread_lock = threading.Lock() 33 | self.outside_motion_count = 0 34 | self.inside_motion_count = 0 35 | self.simulate_kittyflap = simulate_kittyflap 36 | 37 | def init(self): 38 | """Enable both PIRs.""" 39 | Pir.instance = self 40 | 41 | if self.simulate_kittyflap: 42 | logging.info("[PIR] Simulation mode enabled. PIRs are not powered on.") 43 | else: 44 | try: 45 | # Configure GPIO pins for PIRs 46 | gpio.configure(OUTSIDE_POWER_GPIO_NUM, OUTSIDE_POWER_GPIO_DIR) 47 | gpio.configure(INSIDE_POWER_GPIO_NUM, INSIDE_POWER_GPIO_DIR) 48 | gpio.configure(OUTSIDE_PIR_GPIO_NUM, OUTSIDE_PIR_GPIO_DIR) 49 | gpio.configure(INSIDE_PIR_GPIO_NUM, INSIDE_PIR_GPIO_DIR) 50 | 51 | # Power on the PIRs 52 | gpio.set(OUTSIDE_POWER_GPIO_NUM, 1) 53 | gpio.set(INSIDE_POWER_GPIO_NUM, 1) 54 | except Exception as e: 55 | logging.error(f"[PIR] Error initializing PIRs: {e}") 56 | else: 57 | logging.info("[PIR] PIRs initialized and powered on.") 58 | 59 | def read(self): 60 | """Continuously read the state of both PIRs and update shared states.""" 61 | # Register task in the sigterm_monitor object 62 | sigterm_monitor.register_task() 63 | 64 | while not sigterm_monitor.stop_now: 65 | try: 66 | if self.simulate_kittyflap: 67 | # Simulate motion detection with 5% chance and keep the state active for 5-10 seconds 68 | if self.state_outside == 0 and random.random() < 0.05: 69 | state_outside = 1 70 | threading.Timer(random.uniform(5, 10), lambda: self.update_state("OUTSIDE", 0)).start() 71 | else: 72 | state_outside = self.state_outside 73 | 74 | if self.state_inside == 0 and random.random() < 0.05: 75 | state_inside = 1 76 | threading.Timer(random.uniform(5, 10), lambda: self.update_state("INSIDE", 0)).start() 77 | else: 78 | state_inside = self.state_inside 79 | else: 80 | # No simulation, read actual PIR states 81 | try: 82 | # Hysteresis for PIR readings, based on the thresholds defined in CONFIG 83 | # NOTE: The hysteresis applies only to the 'motion' state. A single 'no motion' reading will reset the count. 84 | outside_reading = 1 - gpio.get(OUTSIDE_PIR_GPIO_NUM) # 0 -> motion, 1 -> no motion 85 | inside_reading = 1 - gpio.get(INSIDE_PIR_GPIO_NUM) # 0 -> motion, 1 -> no motion 86 | 87 | if outside_reading == 1: 88 | self.outside_motion_count += 1 89 | else: 90 | self.outside_motion_count = 0 91 | state_outside = 0 92 | state_outside_raw = 0 93 | 94 | if inside_reading == 1: 95 | self.inside_motion_count += 1 96 | else: 97 | self.inside_motion_count = 0 98 | state_inside = 0 99 | state_inside_raw = 0 100 | 101 | if self.outside_motion_count >= (CONFIG['PIR_OUTSIDE_THRESHOLD'] / PIR_READ_INTERVAL): 102 | state_outside = 1 103 | if self.outside_motion_count > 0: 104 | state_outside_raw = 1 105 | 106 | if self.inside_motion_count >= (CONFIG['PIR_INSIDE_THRESHOLD'] / PIR_READ_INTERVAL): 107 | state_inside = 1 108 | if self.inside_motion_count > 0: 109 | state_inside_raw = 1 110 | except: 111 | # Ignore errors. Error logging is done in gpio.get() 112 | pass 113 | 114 | # Log only changes in state 115 | if self.state_outside != state_outside: 116 | if state_outside == 1: 117 | logging.info(f"[PIR] OUTSIDE: Motion detected") 118 | else: 119 | logging.info(f"[PIR] OUTSIDE: No motion") 120 | 121 | if self.state_inside != state_inside: 122 | if state_inside == 1: 123 | logging.info(f"[PIR] INSIDE: Motion detected") 124 | else: 125 | logging.info(f"[PIR] INSIDE: No motion") 126 | 127 | if self.state_outside_raw != state_outside_raw: 128 | if state_outside_raw == 1: 129 | logging.info(f"[PIR] OUTSIDE RAW: Motion detected") 130 | else: 131 | logging.info(f"[PIR] OUTSIDE RAW: No motion") 132 | 133 | if self.state_inside_raw != state_inside_raw: 134 | if state_inside_raw == 1: 135 | logging.info(f"[PIR] INSIDE RAW: Motion detected") 136 | else: 137 | logging.info(f"[PIR] INSIDE RAW: No motion") 138 | 139 | with self.thread_lock: 140 | self.state_outside = state_outside 141 | self.state_inside = state_inside 142 | self.state_outside_raw = state_outside_raw 143 | self.state_inside_raw = state_inside_raw 144 | 145 | except Exception as e: 146 | logging.error(f"[PIR] Error reading PIR states: {e}") 147 | 148 | tm.sleep(PIR_READ_INTERVAL) 149 | 150 | logging.info("[PIR] Stopped PIR monitoring thread.") 151 | sigterm_monitor.signal_task_done() 152 | 153 | def update_state(self, pir, state): 154 | """ 155 | Thread-safe method to update the state of a PIR sensor. 156 | 157 | Args: 158 | pir (str): The identifier of the PIR sensor. Expected values are "OUTSIDE" or "INSIDE". 159 | state (bool): The new state of the PIR sensor. Typically True for active/motion detected, False for inactive/no motion. 160 | """ 161 | with self.thread_lock: 162 | if pir == "OUTSIDE": 163 | self.state_outside = state 164 | elif pir == "INSIDE": 165 | self.state_inside = state 166 | 167 | def get_states(self): 168 | """ 169 | Thread-safe method to read the current states of the PIRs. 170 | 171 | Returns: 172 | tuple: A tuple containing: state_outside, state_inside 173 | (0 = no motion, 1 = motion detected) 174 | """ 175 | with self.thread_lock: 176 | return self.state_outside, self.state_inside, self.state_outside_raw, self.state_inside_raw 177 | -------------------------------------------------------------------------------- /src/shiny_wrappers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | __all__ = ("input_file",) 4 | 5 | from typing import Literal, Optional 6 | 7 | from htmltools import Tag, TagChild, css, div, span, tags 8 | 9 | from shiny._docstring import add_example 10 | from shiny._namespaces import resolve_id 11 | from shiny.ui._utils import shiny_input_label 12 | 13 | class uix: 14 | """A wrapper class for the shiny ui module.""" 15 | 16 | #@add_example() 17 | @staticmethod 18 | def input_file( 19 | id: str, 20 | label: TagChild, 21 | *, 22 | multiple: bool = False, 23 | accept: Optional[str | list[str]] = None, 24 | width: Optional[str] = None, 25 | button_label: str = "Browse...", 26 | placeholder: str = "No file selected", 27 | capture: Optional[Literal["environment", "user"]] = None, 28 | ) -> Tag: 29 | """ 30 | Fixed version of ui.input_file: This function prevents the scrolling to the top of the 31 | page when the file input is clicked. 32 | 33 | Create a file upload control that can be used to upload one or more files. 34 | 35 | Parameters 36 | ---------- 37 | id 38 | An input id. 39 | label 40 | An input label. 41 | multiple 42 | Whether the user should be allowed to select and upload multiple files at once. 43 | accept 44 | Unique file type specifier(s) which give the browser a hint as to the type of 45 | file the server expects. Many browsers use this to prevent the user from 46 | selecting an invalid file. Examples of valid values include a case insensitive 47 | extension (e.g. ``.csv`` or ``.rds``), a valid MIME type (e.g. ``text/plain`` or 48 | ``application/pdf``) or one of ``audio/*``, ``video/*``, or ``image/*`` meaning 49 | any audio, video, or image type, respectively. 50 | width 51 | The CSS width, e.g. '400px', or '100%' 52 | button_label 53 | The label used on the button. 54 | placeholder 55 | The text to show on the input before a file has been uploaded. 56 | capture 57 | On mobile devices, this can be used to open the device's camera for input. If 58 | "environment", it will open the rear-facing camera. If "user", it will open the 59 | front-facing camera. By default, it will accept either still photos or video. To 60 | accept only still photos, use ``accept="image/*"``; to accept only video, use 61 | ``accept="video/*"``. 62 | 63 | Returns 64 | ------- 65 | : 66 | A UI element. 67 | 68 | Notes 69 | ----- 70 | 71 | ::: {.callout-note title="Server value"} 72 | A list of dictionaries (one for each file upload) with the following keys: 73 | 74 | * ``name``: The filename provided by the web browser. This is *not* the path to read 75 | to get at the actual data that was uploaded (see 'datapath'). 76 | * ``size``: The size of the uploaded data, in bytes. 77 | * ``type``: The MIME type reported by the browser (for example, 'text/plain'), or 78 | empty string if the browser didn't know. 79 | * ``datapath``: The path to a temp file that contains the data that was uploaded. 80 | This file may be deleted if the user performs another upload operation. 81 | ::: 82 | 83 | See Also 84 | -------- 85 | * :func:`~shiny.ui.download_button` 86 | """ 87 | 88 | if isinstance(accept, str): 89 | accept = [accept] 90 | 91 | resolved_id = resolve_id(id) 92 | btn_file = span( 93 | button_label, 94 | tags.input( 95 | id=resolved_id, 96 | name=resolved_id, 97 | type="file", 98 | multiple="multiple" if multiple else None, 99 | accept=",".join(accept) if accept else None, 100 | capture=capture, 101 | # The original input_file function has a bad implementation, where the page is scrolled to the top when the file input is clicked: 102 | # Don't use "display: none;" style, which causes keyboard accessibility issue; instead use the following workaround: https://css-tricks.com/places-its-tempting-to-use-display-none-but-dont/ 103 | #style="position: absolute !important; top: -99999px !important; left: -99999px !important;", 104 | 105 | # Fixed version: This function prevents the scrolling to the top of the page when the file input is clicked. 106 | # The input is hidden, but the button is still clickable. 107 | style="display: none !important;", 108 | class_="shiny-input-file-fixed", 109 | ), 110 | class_="btn btn-default btn-file", 111 | ) 112 | return div( 113 | shiny_input_label(resolved_id, label), 114 | div( 115 | tags.label(btn_file, class_="input-group-btn input-group-prepend"), 116 | tags.input( 117 | type="text", 118 | class_="form-control", 119 | placeholder=placeholder, 120 | readonly="readonly", 121 | ), 122 | class_="input-group", 123 | ), 124 | div( 125 | div(class_="progress-bar"), 126 | id=resolved_id + "_progress", 127 | class_="progress active shiny-file-input-progress", 128 | ), 129 | class_="form-group shiny-input-container", 130 | style=css(width=width), 131 | ) 132 | -------------------------------------------------------------------------------- /src/ui.py: -------------------------------------------------------------------------------- 1 | from shiny import ui 2 | from faicons import icon_svg 3 | from pathlib import Path 4 | from src.baseconfig import CONFIG, set_language 5 | 6 | js_file = Path(__file__).parent / "js" / "app.js" 7 | 8 | # Prepare gettext for translations 9 | _ = set_language(CONFIG['LANGUAGE']) 10 | 11 | # the main kittyhack ui 12 | app_ui = ui.page_fillable( 13 | ui.include_js(js_file), 14 | ui.include_css("styles.css"), 15 | ui.head_content( 16 | ui.tags.link(rel="manifest", href="manifest.json"), 17 | ui.tags.link(rel="icon", type="image/png", sizes="64x64", href="favicon-64x64.png"), 18 | ui.tags.link(rel="icon", type="image/png", sizes="32x32", href="favicon-32x32.png"), 19 | ui.tags.link(rel="icon", type="image/png", sizes="16x16", href="favicon-16x16.png"), 20 | ), 21 | ui.navset_bar( 22 | ui.nav_panel( 23 | _("Live view"), 24 | ui.output_ui("ui_live_view"), 25 | ui.output_ui("ui_live_view_footer"), 26 | ui.output_ui("ui_last_events"), 27 | ), 28 | ui.nav_panel( 29 | _("Pictures"), 30 | ui.output_ui("ui_photos_date"), 31 | ui.output_ui("ui_photos_events"), 32 | ui.br(), 33 | ), 34 | ui.nav_panel( 35 | _("Manage cats"), 36 | ui.output_ui("ui_manage_cats"), 37 | ui.br(), 38 | ), 39 | ui.nav_panel( 40 | _("Add new cat"), 41 | ui.output_ui("ui_add_new_cat"), 42 | ui.br(), 43 | ), 44 | ui.nav_panel( 45 | _("AI Training"), 46 | ui.output_ui("ui_ai_training"), 47 | ui.br(), 48 | ), 49 | ui.nav_panel( 50 | _("System"), 51 | ui.output_ui("ui_system") 52 | ), 53 | ui.nav_panel( 54 | _("Configuration"), 55 | ui.output_ui("ui_configuration") 56 | ), 57 | ui.nav_panel( 58 | _("WLAN Configuration"), 59 | ui.output_ui("ui_wlan_configured_connections"), 60 | ui.output_ui("ui_wlan_available_networks"), 61 | ), 62 | ui.nav_panel( 63 | _("Info"), 64 | ui.output_ui("ui_info") 65 | ), 66 | title=ui.HTML("KITTY " + str(icon_svg("shield-cat")) + "HACK"), 67 | position="fixed-top", 68 | padding="3rem", 69 | ), 70 | ) 71 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* Style of the captured pictures */ 2 | .image-container, .generic-container { 3 | text-align: center; 4 | max-width: 800px; 5 | width: 100%; 6 | overflow:hidden; 7 | margin: 0 auto; 8 | } 9 | 10 | .align-left { 11 | text-align: left; 12 | } 13 | 14 | .image-container img { 15 | border-radius: .5rem; 16 | width: 100%; 17 | max-width: 800px; 18 | height: auto; 19 | object-fit: cover; 20 | object-position: center; 21 | margin-top: .25rem; 22 | margin-bottom: .25rem; 23 | outline: 4px solid rgba(250, 250, 250); 24 | outline-offset: -2px; 25 | } 26 | 27 | .generic-container table { 28 | width: 100% !important; 29 | margin: 0 auto; 30 | } 31 | 32 | .generic-container table th, 33 | .generic-container table td { 34 | text-align: center; 35 | background-color: transparent !important; 36 | vertical-align: middle; 37 | word-break: break-word; 38 | overflow-wrap: anywhere; 39 | } 40 | 41 | .generic-container table td:first-child { 42 | white-space: nowrap; 43 | word-break: keep-all; 44 | overflow-wrap: normal; 45 | } 46 | 47 | .generic-container table tr:not(:last-child) td { 48 | border-bottom: 1px solid rgba(0, 0, 0, 0.25); 49 | } 50 | 51 | .image-container-alert { 52 | background-color: rgb(255 251 223) !important; 53 | } 54 | 55 | .image-container-alert img { 56 | outline: 4px solid rgb(255 251 223) !important; 57 | } 58 | 59 | .html-fill-container { 60 | padding-left: 0.2rem !important; 61 | padding-right: 0.2rem !important; 62 | } 63 | 64 | .no-images-found { 65 | text-align: center; 66 | width: 100%; 67 | margin: 0 auto; 68 | } 69 | 70 | .card { 71 | background-color: rgba(250, 250, 250, .9); 72 | border: 1px solid rgba(233, 236, 239, .9); 73 | border-radius: .5rem; 74 | box-shadow: 0px 1px 2px rgba(0, 0, 0, .1), 0px 3px 7px rgba(0, 0, 0, .1), 0px 12px 30px rgba(0, 0, 0, .08); 75 | margin-top: 2rem; 76 | padding: .25rem; 77 | } 78 | 79 | .card-body { 80 | padding: 0px; 81 | overflow: hidden !important; 82 | } 83 | 84 | #ui_photos_range_selector { 85 | margin-left: auto; 86 | margin-right: auto; 87 | width: "600px"; 88 | } 89 | 90 | #date_selector { 91 | display: flex; 92 | justify-content: center; 93 | align-items: center; 94 | text-align: center; 95 | width: auto; 96 | margin: 0 auto; 97 | } 98 | 99 | #date_selector .form-control { 100 | text-align: center; 101 | } 102 | 103 | .btn-date-control { 104 | height: 36.7px; 105 | padding: 7px; 106 | width: 36.7px; 107 | vertical-align:middle; 108 | margin-top: 10px; 109 | margin-bottom: 10px; 110 | } 111 | 112 | .btn-date-filter { 113 | height: 36.7px; 114 | width: auto; 115 | padding: 7px; 116 | vertical-align:middle; 117 | } 118 | 119 | .btn-config { 120 | padding: 7px; 121 | vertical-align:middle; 122 | } 123 | 124 | .btn-no-border { 125 | border-color: transparent; 126 | padding: 0.1rem; 127 | } 128 | 129 | .table_nobgcolor { 130 | --bs-table-bg: transparent !important; 131 | } 132 | 133 | .table_horscrollbar { 134 | overflow-x: auto; 135 | width: 100%; 136 | } 137 | 138 | .placeholder-image { 139 | display: grid; 140 | place-items: center; 141 | height: 100%; 142 | width: 100%; 143 | min-height: 250px; 144 | background-color: #f8f9fa; 145 | color: #6c757d; 146 | border: 2px dashed #dee2e6; 147 | border-radius: 4px; 148 | } 149 | 150 | .spinner { 151 | width: 50px; 152 | height: 50px; 153 | border: 5px solid rgba(0, 0, 0, 0.1); 154 | border-left-color: #333; 155 | border-radius: 50%; 156 | animation: spin 1s linear infinite; 157 | } 158 | 159 | @keyframes spin { 160 | 0% { transform: rotate(0deg); } 161 | 100% { transform: rotate(360deg); } 162 | } 163 | 164 | .spinner-container { 165 | display: flex; 166 | justify-content: center; 167 | align-items: center; 168 | height: 150px; 169 | } 170 | 171 | .disabled-wrapper { 172 | pointer-events: none; 173 | opacity: 0.5; 174 | } 175 | 176 | .btn-narrow { 177 | padding: 0.5em 1em; 178 | } 179 | 180 | .btn-vertical-margin { 181 | margin-top: 0.15em; 182 | margin-bottom: 0.15em; 183 | } 184 | 185 | .btn-danger-custom { 186 | background-color: #dc3545; 187 | color: white; 188 | border: none; 189 | /*padding: 10px 16px; 190 | font-size: 16px; 191 | border-radius: 5px;*/ 192 | cursor: pointer; 193 | transition: background-color 0.3s, transform 0.1s; 194 | } 195 | 196 | .btn-danger-custom:hover { 197 | background-color: #c82333; 198 | } 199 | 200 | .btn-danger-custom:active { 201 | background-color: #a71d2a; 202 | transform: scale(0.98); 203 | } 204 | 205 | .btn-danger-custom:disabled { 206 | background-color: #e0a0a5; 207 | cursor: not-allowed; 208 | opacity: 0.6; 209 | } 210 | 211 | /* Override bootstrap configuration for modal */ 212 | @media screen and (min-width: 576px) { 213 | .modal { 214 | --bs-modal-margin: 2rem; /* Fix incorrect vertical position on wider screens */ 215 | } 216 | } 217 | 218 | .modal-content:has(.transparent-modal-content) { 219 | background: rgb(0 0 0 / 0%) !important; 220 | border: 0 !important; 221 | } 222 | 223 | .transparent-modal-content { 224 | padding: 0 !important; 225 | } 226 | .transparent-modal-content + div { 227 | padding: 0 !important; 228 | } 229 | 230 | 231 | /* Live view panel - Event table */ 232 | .date-separator-row td { 233 | font-weight: bold; 234 | text-align: center; 235 | border-bottom: 1px solid #dee2e6; 236 | border-top: 1px solid #dee2e6; 237 | padding: 20px 0 5px 0; 238 | background: linear-gradient(to bottom, rgba(255, 255, 255, 0) 0%, rgba(230, 230, 230, 0.25) 100%); 239 | } 240 | 241 | .event-date-separator { 242 | font-weight: bold; 243 | text-align: center; 244 | } 245 | 246 | .tooltip-wrapper { 247 | cursor: default; 248 | } 249 | 250 | #shiny-disconnected-overlay { 251 | opacity: 0.75; 252 | } 253 | 254 | #shiny-disconnected-overlay::after { 255 | content: "Connection lost.\A Trying to reconnect..."; 256 | font-size: 1.5rem; 257 | font-weight: bold; 258 | color: rgb(255 237 115); 259 | text-shadow: 2px 2px 4px black; 260 | position: absolute; 261 | top: 50%; 262 | left: 50%; 263 | transform: translate(-50%, -50%); 264 | background-color: #252525; 265 | padding: 20px 40px; 266 | border-radius: 10px; 267 | z-index: 99999; 268 | text-align: center; 269 | max-width: 80%; 270 | white-space: pre-wrap; 271 | } 272 | 273 | .card:has(#bResetPreyCooldown:disabled) { 274 | display: none; 275 | } 276 | 277 | .table_models_overview { 278 | table-layout: auto; 279 | width: 100%; 280 | } 281 | 282 | .table_models_overview td { 283 | white-space: normal !important; 284 | word-break: break-word !important; 285 | overflow-wrap: break-word !important; 286 | } 287 | 288 | .table_models_overview th:first-child, 289 | .table_models_overview td:first-child { 290 | /* Name column */ 291 | max-width: 70%; 292 | width: 70%; 293 | } 294 | 295 | .table_models_overview th:nth-child(2), 296 | .table_models_overview td:nth-child(2) { 297 | /* Date column */ 298 | min-width: 110px; 299 | } 300 | 301 | .table_models_overview th:last-child, 302 | .table_models_overview td:last-child { 303 | /* Action button column */ 304 | width: 55px; 305 | min-width: 55px; 306 | max-width: 55px; 307 | text-align: center; 308 | } 309 | 310 | .release_notes { 311 | background-color: #f8f9fa; 312 | border: 1px solid #d1d5db; 313 | border-radius: 8px; 314 | padding: 1rem; 315 | margin-top: 0.5rem; 316 | margin-bottom: 0.5rem; 317 | font-size: 0.8em !important; 318 | text-align: left !important; 319 | } 320 | 321 | .release_notes h1 { 322 | font-size: 1.3em; 323 | } 324 | .release_notes h2 { 325 | font-size: 1.1em; 326 | } 327 | .release_notes h3 { 328 | font-size: 1em; 329 | } 330 | .release_notes h4 { 331 | font-size: 0.95em; 332 | } 333 | .release_notes h5 { 334 | font-size: 0.9em; 335 | } 336 | .release_notes h6 { 337 | font-size: 0.85em; 338 | } 339 | 340 | .collapsible-header-btn { 341 | width: 100%; 342 | text-align: left; 343 | background: #f8f9fa; 344 | border: 1px solid #dee2e6; 345 | padding: 0.75em 1.2em; 346 | font-size: larger; 347 | font-weight: 400; 348 | color: #212529; 349 | display: flex; 350 | align-items: center; 351 | transition: background 0.2s; 352 | border-radius: 0.5rem 0.5rem 0 0; 353 | border-bottom: none; 354 | margin-bottom: 0; 355 | } 356 | .collapsible-header-btn:hover, .collapsible-header-btn:focus { 357 | background: #e9ecef; 358 | text-decoration: none; 359 | color: #212529; 360 | } 361 | .collapsible-chevron { 362 | margin-right: 0.7em; 363 | transition: transform 0.3s; 364 | font-size: 1.2em; 365 | } 366 | .collapsible-header-btn[aria-expanded="true"] .collapsible-chevron { 367 | transform: rotate(90deg); 368 | } 369 | .collapsible-section-intro { 370 | color: #666; 371 | margin-bottom: 0.5em; 372 | max-width: 800px; 373 | margin-left: auto; 374 | margin-right: auto; 375 | width: 100%; 376 | text-align: left; 377 | padding-bottom: 1em; 378 | } 379 | 380 | .collapsible-section { 381 | max-width: 800px; 382 | margin-left: auto; 383 | margin-right: auto; 384 | width: 100%; 385 | display: block; 386 | border-radius: 0.5rem; 387 | box-shadow: 0px 1px 2px rgba(0,0,0,.1), 0px 3px 7px rgba(0,0,0,.1), 0px 12px 30px rgba(0,0,0,.08); 388 | background: rgba(250,250,250,0.9); 389 | border: 1px solid #e9ecef; 390 | padding: 0; 391 | overflow: hidden; 392 | } 393 | 394 | .collapsible-section > .collapse, 395 | .collapsible-section > .collapse.show, 396 | .collapsible-section > div[id$="_body"] { 397 | max-width: 800px; 398 | margin-left: auto; 399 | margin-right: auto; 400 | width: 100%; 401 | display: block; 402 | border-radius: 0 0 0.5rem 0.5rem; 403 | border-top: 1px solid #e9ecef; 404 | background: transparent; 405 | /* Remove top margin to connect with header */ 406 | margin-top: 0; 407 | padding-bottom: 1em; 408 | } 409 | 410 | /* Remove double border between header and expanded part */ 411 | .collapsible-section > .collapse, 412 | .collapsible-section > .collapse.show { 413 | border-top: none; 414 | } 415 | 416 | /* Optional: Remove background from .collapsible-section-intro if you want it to blend in */ 417 | .collapsible-section-intro { 418 | background: transparent; 419 | border: none; 420 | padding-left: 1em; 421 | padding-right: 1em; 422 | } 423 | 424 | .warning-container { 425 | background: #fcfcf4; 426 | border: 1px solid #ffe58f; 427 | color: #8a6d3b; 428 | border-radius: 8px; 429 | padding: 6px 12px; 430 | margin-top: 8px; 431 | font-size: 0.8em; 432 | display: inline-block; 433 | box-shadow: 0 1px 4px rgba(0,0,0,0.01); 434 | } -------------------------------------------------------------------------------- /tflite/cv-lite-model.tflite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/tflite/cv-lite-model.tflite -------------------------------------------------------------------------------- /tflite/labels.txt: -------------------------------------------------------------------------------- 1 | false-accept 2 | Keine Maus 3 | Maus -------------------------------------------------------------------------------- /tflite/original_kittyflap_model_v1/cv-lite-model.tflite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/tflite/original_kittyflap_model_v1/cv-lite-model.tflite -------------------------------------------------------------------------------- /tflite/original_kittyflap_model_v1/labels.txt: -------------------------------------------------------------------------------- 1 | Keine Maus 2 | Maus -------------------------------------------------------------------------------- /tflite/original_kittyflap_model_v2/cv-lite-model.tflite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/tflite/original_kittyflap_model_v2/cv-lite-model.tflite -------------------------------------------------------------------------------- /tflite/original_kittyflap_model_v2/labels.txt: -------------------------------------------------------------------------------- 1 | false-accept 2 | Keine Maus 3 | Maus -------------------------------------------------------------------------------- /www/favicon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-120x120.png -------------------------------------------------------------------------------- /www/favicon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-128x128.png -------------------------------------------------------------------------------- /www/favicon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-144x144.png -------------------------------------------------------------------------------- /www/favicon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-152x152.png -------------------------------------------------------------------------------- /www/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-16x16.png -------------------------------------------------------------------------------- /www/favicon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-180x180.png -------------------------------------------------------------------------------- /www/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-192x192.png -------------------------------------------------------------------------------- /www/favicon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-256x256.png -------------------------------------------------------------------------------- /www/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-32x32.png -------------------------------------------------------------------------------- /www/favicon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-384x384.png -------------------------------------------------------------------------------- /www/favicon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-512x512.png -------------------------------------------------------------------------------- /www/favicon-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-64x64.png -------------------------------------------------------------------------------- /www/favicon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-72x72.png -------------------------------------------------------------------------------- /www/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon-96x96.png -------------------------------------------------------------------------------- /www/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floppyFK/kittyhack/ac8ead600a2cdd88804ac24419cafb8007265ab8/www/favicon.ico -------------------------------------------------------------------------------- /www/icons/mouse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /www/icons/prey-frame-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /www/icons/prey-frame-on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /www/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Kittyhack", 3 | "short_name": "Kittyhack", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "orientation": "portrait", 7 | "background_color": "#FFFFFF", 8 | "theme_color": "#FFFFFF", 9 | "description": "Kittyhack", 10 | "scope": "/", 11 | "icons": [ 12 | { 13 | "src": "favicon-512x512.png", 14 | "sizes": "512x512", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "favicon-384x384.png", 19 | "sizes": "384x384", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "favicon-256x256.png", 24 | "sizes": "256x256", 25 | "type": "image/png" 26 | }, 27 | { 28 | "src": "favicon-192x192.png", 29 | "sizes": "192x192", 30 | "type": "image/png" 31 | }, 32 | { 33 | "src": "favicon-180x180.png", 34 | "sizes": "180x180", 35 | "type": "image/png" 36 | }, 37 | { 38 | "src": "favicon-152x152.png", 39 | "sizes": "152x152", 40 | "type": "image/png" 41 | }, 42 | { 43 | "src": "favicon-144x144.png", 44 | "sizes": "144x144", 45 | "type": "image/png" 46 | }, 47 | { 48 | "src": "favicon-128x128.png", 49 | "sizes": "128x128", 50 | "type": "image/png" 51 | }, 52 | { 53 | "src": "favicon-120x120.png", 54 | "sizes": "120x120", 55 | "type": "image/png" 56 | }, 57 | { 58 | "src": "favicon-96x96.png", 59 | "sizes": "96x96", 60 | "type": "image/png" 61 | }, 62 | { 63 | "src": "favicon-72x72.png", 64 | "sizes": "72x72", 65 | "type": "image/png" 66 | }, 67 | { 68 | "src": "favicon-64x64.png", 69 | "sizes": "64x64", 70 | "type": "image/png" 71 | } 72 | ], 73 | "categories": ["utilities"] 74 | } -------------------------------------------------------------------------------- /www/offline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | KITTYHACK 7 | 90 | 148 | 149 | 150 |
151 |
152 |
KITTYHACK
153 |
154 |
155 |
⚠️
156 |

Not reachable

157 |

The connection to your Kittyflap has been lost. This might be due to network issues or the device being powered off.

158 | 161 |
Initializing...
162 |
163 |
164 | 165 | -------------------------------------------------------------------------------- /www/pwa-service-worker.js: -------------------------------------------------------------------------------- 1 | // Service Worker for online-only PWA with offline notification 2 | 3 | // Version your cache to force updates 4 | const CACHE_VERSION = '1'; 5 | const CACHE_NAME = 'offline-only-v' + CACHE_VERSION; 6 | 7 | // The offline fallback page 8 | const OFFLINE_PAGE = './offline.html'; 9 | 10 | // Install event - cache the offline page 11 | self.addEventListener('install', event => { 12 | console.log('[Service Worker] Installing'); 13 | event.waitUntil( 14 | caches.open(CACHE_NAME).then(cache => { 15 | console.log('[Service Worker] Caching offline page'); 16 | // Use no-cache to force revalidation 17 | return cache.add(new Request(OFFLINE_PAGE, { cache: 'no-cache' })); 18 | }) 19 | ); 20 | // Activate immediately 21 | self.skipWaiting(); 22 | }); 23 | 24 | // Activate event - clean up any old caches 25 | self.addEventListener('activate', event => { 26 | console.log('[Service Worker] Activating'); 27 | event.waitUntil( 28 | caches.keys().then(cacheNames => { 29 | return Promise.all( 30 | cacheNames.filter(cacheName => { 31 | return cacheName.startsWith('offline-only-') && cacheName !== CACHE_NAME; 32 | }).map(cacheName => { 33 | console.log('[Service Worker] Removing old cache', cacheName); 34 | return caches.delete(cacheName); 35 | }) 36 | ); 37 | }).then(() => { 38 | // Update the offline page whenever the service worker activates 39 | return caches.open(CACHE_NAME).then(cache => { 40 | console.log('[Service Worker] Re-caching offline page on activation'); 41 | return cache.add(new Request(OFFLINE_PAGE, { cache: 'reload' })); 42 | }); 43 | }) 44 | ); 45 | // Take control of all clients 46 | return self.clients.claim(); 47 | }); 48 | 49 | // Fetch event - try network first, show offline page if network fails or server returns 5xx 50 | self.addEventListener('fetch', event => { 51 | // Only handle GET requests 52 | if (event.request.method !== 'GET') return; 53 | 54 | // Check if this is a navigation request (HTML page) 55 | const isNavigationRequest = event.request.mode === 'navigate'; 56 | 57 | event.respondWith( 58 | fetch(event.request) 59 | .then(response => { 60 | // Check if response is ok (status 200-399) 61 | // If server returns 5xx, show offline page for navigation requests 62 | if (!response.ok && response.status >= 500 && response.status < 600) { 63 | console.log('[Service Worker] Server error', response.status); 64 | 65 | if (isNavigationRequest) { 66 | return caches.match(OFFLINE_PAGE); 67 | } 68 | } 69 | 70 | // For successful responses or non-5xx errors, return the response as-is 71 | return response; 72 | }) 73 | .catch(() => { 74 | console.log('[Service Worker] Network request failed, serving offline page'); 75 | 76 | // If it's a navigation request and network fails, serve offline page 77 | if (isNavigationRequest) { 78 | return caches.match(OFFLINE_PAGE); 79 | } 80 | 81 | // For other resources (images, scripts, etc.), return a network error 82 | return new Response('Network error', { 83 | status: 503, 84 | headers: { 'Content-Type': 'text/plain' } 85 | }); 86 | }) 87 | ); 88 | }); --------------------------------------------------------------------------------