├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
17 |
--------------------------------------------------------------------------------
/www/icons/prey-frame-off.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
--------------------------------------------------------------------------------
/www/icons/prey-frame-on.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
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 | });
--------------------------------------------------------------------------------