chore: delete the Python code after migration to Go

This commit is contained in:
Ingmar Stein
2025-12-13 13:32:41 +01:00
parent 5fd9bfe10e
commit 7eb7307132
77 changed files with 0 additions and 20786 deletions

View File

@@ -1,160 +0,0 @@
# Add App Page - System Apps Version Display
## Overview
Added display of system-apps version (commit hash) next to the "System Apps" title on the add app page, with a clickable link to the GitHub repository at that specific commit.
## Changes Made
### 1. Manager Route (`tronbyt_server/manager.py`)
Updated the `addapp` route to pass `system_repo_info` to the template:
```python
# Sort apps_list so that installed apps appear first
apps_list.sort(key=lambda app_metadata: not app_metadata["is_installed"])
system_repo_info = system_apps.get_system_repo_info(db.get_data_dir())
return render_template(
"manager/addapp.html",
device=g.user["devices"][device_id],
apps_list=apps_list,
custom_apps_list=custom_apps_list,
system_repo_info=system_repo_info,
)
```
### 2. Add App Template (`tronbyt_server/templates/manager/addapp.html`)
#### Updated the `render_app_list` Macro
Added `show_version` parameter to the macro and inline version display:
```html
{% macro render_app_list(title, search_id, grid_id, app_list, show_controls=true, show_version=false) %}
<div class="app-group">
<h3 style="display: inline-block; margin-right: 10px;">{{ title }}</h3>
{% if show_version and system_repo_info and system_repo_info.commit_hash %}
<span style="font-size: 14px; color: #888;">
(version: <a href="{{ system_repo_info.commit_url }}" target="_blank" style="color: #4CAF50; text-decoration: none;">{{ system_repo_info.commit_hash }}</a>)
</span>
{% endif %}
...
```
#### Updated System Apps Call
Enabled version display for System Apps:
```html
{{ render_app_list(_('System Apps'), 'system_search', 'system_app_grid', apps_list, show_version=true) }}
```
## Features
### Display Information
- **Inline with Title**: Version appears on the same line as "System Apps" heading
- **Commit Hash**: Shows the short commit hash (7 characters)
- **Clickable Link**: Links to the exact commit on GitHub
- **Subtle Styling**: Gray text with green link to not distract from main content
### Visual Design
- Inline display: `System Apps (version: abc1234)`
- H3 heading uses `display: inline-block` to allow inline version text
- Version text is smaller (14px) and gray (#888)
- Commit hash link is green (#4CAF50) matching site theme
- Opens in new tab (`target="_blank"`)
### Conditional Display
- Only shows if `show_version=true` is passed to macro
- Only shows if `system_repo_info` exists
- Only shows if `commit_hash` is available
- Custom Apps section does NOT show version (only System Apps)
## User Experience
### On Add App Page
Users will see:
```
Custom Apps
[app grid...]
─────────────────────────────────
System Apps (version: abc1234)
↑ clickable link to GitHub
[search and filter controls]
[app grid...]
```
### Example Display
```
System Apps (version: a1b2c3d)
```
Where "a1b2c3d" is a clickable link to:
`https://github.com/tronbyt/apps/tree/a1b2c3d1234567890abcdef`
## Benefits
1. **Visibility**: Users can see which version of system apps is available
2. **Debugging**: Easy to verify which commit is deployed when reporting issues
3. **Traceability**: Direct link to view the exact code on GitHub
4. **Non-intrusive**: Subtle styling doesn't distract from app selection
5. **Consistent**: Matches the pattern used on firmware and admin pages
6. **Selective**: Only shows for System Apps, not Custom Apps (which are user-specific)
## Technical Notes
- The version is only displayed if the commit hash is available
- If the system-apps directory is not a git repository, the version won't appear
- The commit URL format is: `{repo_url}/tree/{commit_hash}`
- Uses inline-block display to keep title and version on same line
- Fully internationalized with `{{ _() }}` translation markers for "System Apps"
- The macro is reusable - can be enabled for other app lists if needed
## Files Modified
1. `tronbyt_server/manager.py` - Added system_repo_info to addapp route
2. `tronbyt_server/templates/manager/addapp.html` - Added version display to macro and enabled for System Apps
## Testing
To test the feature:
1. Navigate to any device's "Add App" page
2. Scroll to the "System Apps" section
3. Verify the version appears next to the title: `System Apps (version: abc1234)`
4. Click the commit hash link - should open GitHub at that specific commit
5. Verify Custom Apps section does NOT show a version
6. Verify the display is inline and doesn't break the layout
## Comparison with Other Pages
### Admin Settings Page
- Shows detailed box with commit, repo URL, and branch
- Includes management buttons
- More prominent display
### Firmware Generation Page
- Shows version in a styled info box
- Includes management button for admins
- Separate box similar to firmware version
### Add App Page (This Implementation)
- Shows version inline with title
- Subtle, non-intrusive display
- No management buttons (not needed in this context)
- Focuses on quick reference
## Future Enhancements
Possible future improvements:
- Add tooltip showing full commit hash on hover
- Show commit date
- Add "last updated" timestamp
- Include branch name if not on main
- Add visual indicator if version is outdated

View File

@@ -1,292 +0,0 @@
# API Examples on Update App Page
## Overview
Added a comprehensive API examples section to the Update App page that displays ready-to-use curl commands for controlling the app via the API.
## Implementation
### Location
The API examples box appears at the bottom of the Update App page, after the Save/Delete buttons.
### Features
**Four API Operations:**
1. **Enable App** - Turn the app on
2. **Disable App** - Turn the app off
3. **Pin App** - Pin the app to always display
4. **Unpin App** - Remove the pin from the app
### Visual Design
**Box Styling:**
- Dark background (#2a2a2a)
- Green left border (#4CAF50)
- Rounded corners (8px)
- 30px top margin for separation
**Code Blocks:**
- Dark background (#1e1e1e)
- Light gray text (#e0e0e0)
- Horizontal scrolling for long commands
- Monospace font
- 15px padding
**Headers:**
- Green for positive actions (Enable, Pin)
- Orange for negative actions (Disable, Unpin)
## Changes Made
### 1. Manager Route (`tronbyt_server/manager.py`)
**Lines 861-869:**
```python
device = g.user["devices"][device_id]
return render_template(
"manager/updateapp.html",
app=app,
device=device, # NEW - Pass device to template
device_id=device_id,
config=json.dumps(app.get("config", {}), indent=4),
)
```
Added `device` to the template context so we can access the device API key.
### 2. Update App Template (`tronbyt_server/templates/manager/updateapp.html`)
**Lines 209-257:**
Added complete API examples section with four curl commands.
## Example Output
### Enable App
```bash
curl -X PATCH \
-H "Authorization: Bearer abc123xyz..." \
-H "Content-Type: application/json" \
-d '{"set_enabled": true}' \
http://localhost:5000/v0/devices/my-device/installations/clock-123
```
### Disable App
```bash
curl -X PATCH \
-H "Authorization: Bearer abc123xyz..." \
-H "Content-Type: application/json" \
-d '{"set_enabled": false}' \
http://localhost:5000/v0/devices/my-device/installations/clock-123
```
### Pin App
```bash
curl -X PATCH \
-H "Authorization: Bearer abc123xyz..." \
-H "Content-Type: application/json" \
-d '{"set_pinned": true}' \
http://localhost:5000/v0/devices/my-device/installations/clock-123
```
### Unpin App
```bash
curl -X PATCH \
-H "Authorization: Bearer abc123xyz..." \
-H "Content-Type: application/json" \
-d '{"set_pinned": false}' \
http://localhost:5000/v0/devices/my-device/installations/clock-123
```
## Dynamic Values
The curl commands automatically include:
- **API Key**: `{{ device.get('api_key', 'YOUR_API_KEY') }}`
- **Server URL**: `{{ request.url_root }}`
- **Device ID**: `{{ device_id }}`
- **Installation ID**: `{{ app['iname'] }}`
### API Key Handling
- If device has an API key, it's shown in the command
- If no API key, shows placeholder "YOUR_API_KEY"
- Note at bottom reminds users to replace if needed
### Server URL
- Uses `request.url_root` to get the current server URL
- Works with localhost, production domains, custom ports
- Examples:
- `http://localhost:5000/`
- `https://tronbyt.example.com/`
- `http://192.168.1.100:8080/`
## User Benefits
### 1. Copy-Paste Ready
- Commands are complete and ready to use
- No manual editing needed (if API key is set)
- Just copy and paste into terminal
### 2. Learning Tool
- Shows exact API endpoint structure
- Demonstrates proper headers and JSON format
- Helps users understand the API
### 3. Quick Testing
- Test API functionality immediately
- Verify app control works
- Debug integration issues
### 4. Documentation
- Self-documenting API
- Always up-to-date with current values
- No need to look up documentation
## Internationalization
All text is wrapped in `{{ _() }}` for translation:
- Section title: "API Examples"
- Instructions: "Use these curl commands..."
- Operation names: "Enable App", "Disable App", etc.
- Note text: "Replace YOUR_API_KEY..."
## Styling Details
### Section Header
```css
color: #4CAF50;
margin-top: 0;
```
### Instructions
```css
color: #b0b0b0;
margin-bottom: 20px;
```
### Operation Headers
```css
font-size: 1.1em;
margin-bottom: 10px;
color: #4CAF50 (enable/pin) or #ff9800 (disable/unpin)
```
### Code Blocks
```css
background-color: #1e1e1e;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
color: #e0e0e0;
font-size: 0.9em;
```
### Note Text
```css
color: #888;
font-size: 0.9em;
margin-top: 20px;
```
## Technical Details
### Template Variables Used
- `device` - Device object with API key
- `device_id` - Device ID string
- `app['iname']` - Installation ID
- `request.url_root` - Server base URL
### Jinja2 Features
- `{{ device.get('api_key', 'YOUR_API_KEY') }}` - Safe dictionary access with default
- `{{ _('text') }}` - Translation function
- Multi-line strings in `<pre>` tags
### HTTP Details
- **Method**: PATCH (partial update)
- **Endpoint**: `/v0/devices/{device_id}/installations/{installation_id}`
- **Headers**: Authorization (Bearer token), Content-Type (application/json)
- **Body**: JSON with operation key
## Security Considerations
### API Key Display
- Shows actual API key if available
- Helps users who have access to the page
- Users already authenticated to see this page
- API key needed to actually use the commands
### Best Practices
- Commands use HTTPS in production (via `request.url_root`)
- Bearer token authentication
- JSON content type specified
- Proper REST semantics (PATCH for updates)
## Future Enhancements
### Possible Improvements
1. **Copy Button**: Add button to copy command to clipboard
```html
<button onclick="copyToClipboard(this)">Copy</button>
```
2. **Toggle Visibility**: Collapsible section like config/debug
```html
<button id="toggleApiBtn">Show API Examples</button>
```
3. **More Operations**: Add examples for other API endpoints
- Push image
- Update configuration
- Delete installation
4. **Language Selection**: Show examples in different languages
- Python (requests library)
- JavaScript (fetch API)
- PowerShell
5. **Response Examples**: Show expected API responses
```json
{"status": "success", "message": "App enabled."}
```
6. **Error Examples**: Show common error responses
```json
{"error": "Invalid API key"}
```
## Testing Checklist
- [ ] API examples box appears on update app page
- [ ] All four commands are displayed
- [ ] Device API key is shown correctly
- [ ] Server URL is correct
- [ ] Device ID is correct
- [ ] Installation ID is correct
- [ ] Commands are properly formatted
- [ ] Code blocks are scrollable
- [ ] Colors match design (green/orange)
- [ ] Text is translatable
- [ ] Commands work when copied
## Files Modified
1. **`tronbyt_server/manager.py`** (lines 861-869)
- Added `device` to template context
2. **`tronbyt_server/templates/manager/updateapp.html`** (lines 209-257)
- Added API examples section
- Four curl command examples
- Styling and formatting
## Related Features
- API endpoint: `/v0/devices/{device_id}/installations/{installation_id}` (PATCH)
- Operations: `set_enabled`, `set_pinned`
- Authentication: Bearer token (device API key)
## Summary
Added a comprehensive API examples section to the Update App page that provides ready-to-use curl commands for:
- Enabling/disabling the app
- Pinning/unpinning the app
The commands include all necessary values (API key, device ID, installation ID, server URL) and are formatted for easy copy-paste usage. This makes the API more discoverable and easier to use for developers and power users.

View File

@@ -1,273 +0,0 @@
# API Pinning Bug Fix
## Problem
The API pinning endpoint was returning "App pinned." but the app wasn't actually being pinned. The web interface would not show the app as pinned after using the API command.
## Root Cause
The issue was in how the device object was being modified and saved in `tronbyt_server/api.py`.
### Original Code (Buggy)
```python
# Get user for saving changes
user = db.get_user_by_device_id(device_id)
if not user:
abort(HTTPStatus.NOT_FOUND, description="User not found")
apps = device.get("apps", {}) # Using 'device' from line 236
if installation_id not in apps:
abort(HTTPStatus.NOT_FOUND, description="App not found")
if set_pinned:
# Pin the app
device["pinned_app"] = installation_id # ❌ Modifying wrong device object!
db.save_user(user)
return Response("App pinned.", status=200)
```
### The Problem
1. **Line 236**: `device = db.get_device_by_id(device_id)`
- This retrieves a device object from the database
2. **Line 295**: `user = db.get_user_by_device_id(device_id)`
- This retrieves the user object that owns the device
3. **Line 305**: `device["pinned_app"] = installation_id`
- This modifies the `device` variable from line 236
- But this is a **separate copy** of the device, not the one in `user["devices"]`
4. **Line 306**: `db.save_user(user)`
- This saves the user object
- But the user's device dictionary was never modified!
- The changes to `device` are lost
### Why It Happened
The `db.get_device_by_id()` function returns a device object:
```python
def get_device_by_id(device_id: str) -> Optional[Device]:
for user in get_all_users():
device = user.get("devices", {}).get(device_id)
if device:
return device # Returns the device object
return None
```
This returns a reference to the device, but when we later get the user separately and save it, we're not saving the same device object that we modified.
## Solution
Modify the device through the user's device dictionary, not through the standalone device variable.
### Fixed Code
```python
# Get user for saving changes
user = db.get_user_by_device_id(device_id)
if not user:
abort(HTTPStatus.NOT_FOUND, description="User not found")
# Get device from user's devices (not the standalone device variable)
user_device = user["devices"].get(device_id)
if not user_device:
abort(HTTPStatus.NOT_FOUND, description="Device not found in user data")
apps = user_device.get("apps", {}) # ✅ Using user_device
if installation_id not in apps:
abort(HTTPStatus.NOT_FOUND, description="App not found")
if set_pinned:
# Pin the app
user_device["pinned_app"] = installation_id # ✅ Modifying correct device!
db.save_user(user) # ✅ Saves the modified user with updated device
return Response("App pinned.", status=200)
```
### Key Changes
1. **Line 300**: Get device from `user["devices"]` instead of using the standalone `device` variable
2. **Line 310**: Modify `user_device["pinned_app"]` instead of `device["pinned_app"]`
3. **Line 315**: Check `user_device.get("pinned_app")` for unpinning
## Why This Works
```
user = {
"username": "john",
"devices": {
"device-123": { ← This is user_device
"id": "device-123",
"name": "My Device",
"apps": {...},
"pinned_app": None ← We modify this
}
}
}
# When we do:
user_device = user["devices"]["device-123"]
user_device["pinned_app"] = "clock-123"
# We're modifying the device inside the user object
# So when we save the user, the changes persist!
db.save_user(user) # ✅ Saves with pinned_app set
```
## Changes Made
**File: `tronbyt_server/api.py` (lines 286-320)**
### Before
```python
# Get user for saving changes
user = db.get_user_by_device_id(device_id)
if not user:
abort(HTTPStatus.NOT_FOUND, description="User not found")
apps = device.get("apps", {})
if installation_id not in apps:
abort(HTTPStatus.NOT_FOUND, description="App not found")
if set_pinned:
# Pin the app
device["pinned_app"] = installation_id
db.save_user(user)
return Response("App pinned.", status=200)
```
### After
```python
# Get user for saving changes
user = db.get_user_by_device_id(device_id)
if not user:
abort(HTTPStatus.NOT_FOUND, description="User not found")
# Get device from user's devices (not the standalone device variable)
user_device = user["devices"].get(device_id)
if not user_device:
abort(HTTPStatus.NOT_FOUND, description="Device not found in user data")
apps = user_device.get("apps", {})
if installation_id not in apps:
abort(HTTPStatus.NOT_FOUND, description="App not found")
if set_pinned:
# Pin the app
user_device["pinned_app"] = installation_id
db.save_user(user)
return Response("App pinned.", status=200)
```
## Testing
### Test Pin Operation
```bash
curl -X PATCH \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"set_pinned": true}' \
http://localhost:5000/v0/devices/DEVICE_ID/installations/INSTALLATION_ID
```
**Expected:**
- Response: "App pinned."
- Web interface shows app as pinned
- Device displays only the pinned app
- Badge appears next to device name
### Test Unpin Operation
```bash
curl -X PATCH \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"set_pinned": false}' \
http://localhost:5000/v0/devices/DEVICE_ID/installations/INSTALLATION_ID
```
**Expected:**
- Response: "App unpinned."
- Web interface shows app as not pinned
- Device resumes normal app rotation
- Badge disappears from device name
## Related Code
### Web Interface (Working Correctly)
The web interface in `manager.py` already does this correctly:
```python
@bp.get("/<string:device_id>/<string:iname>/toggle_pin")
@login_required
def toggle_pin(device_id: str, iname: str) -> ResponseReturnValue:
user = g.user
device = user["devices"][device_id] # ✅ Gets device from user
if device.get("pinned_app") == iname:
device.pop("pinned_app", None) # ✅ Modifies device in user
else:
device["pinned_app"] = iname # ✅ Modifies device in user
db.save_user(user) # ✅ Saves correctly
return redirect(url_for("manager.index"))
```
### Why set_enabled Worked
The `set_enabled` operation worked because it uses `db.save_app()` which handles the device lookup internally:
```python
if set_enabled:
app["enabled"] = True
app["last_render"] = 0
if db.save_app(device_id, app): # ✅ save_app handles device lookup
return Response("App Enabled.", status=200)
```
## Lessons Learned
### 1. Object References in Python
When you get an object from a dictionary and modify it, you need to make sure you're modifying the object that will be saved, not a copy or separate reference.
### 2. Consistency in Code Patterns
The `set_enabled` operation used `db.save_app()` which worked correctly. The `set_pinned` operation tried to use `db.save_user()` directly but didn't properly update the user's device.
### 3. Testing API Endpoints
Always test API endpoints end-to-end:
1. Call the API
2. Check the response
3. **Verify the change persisted** (check web interface, database, etc.)
## Prevention
### Code Review Checklist
When modifying nested objects:
- [ ] Are you modifying the object that will be saved?
- [ ] Is the object part of a larger structure (user → devices → device)?
- [ ] Does the save operation include your modifications?
- [ ] Have you tested that changes persist after save?
### Better Pattern
Consider creating a helper function:
```python
def update_device_property(device_id: str, property_name: str, value: Any) -> bool:
"""Update a device property and save it."""
user = db.get_user_by_device_id(device_id)
if not user:
return False
device = user["devices"].get(device_id)
if not device:
return False
device[property_name] = value
db.save_user(user)
return True
```
## Summary
Fixed the API pinning bug by ensuring we modify the device object that's part of the user's device dictionary, not a separate device object. The key was to use `user["devices"][device_id]` instead of the standalone `device` variable when making modifications that need to persist.
**Status:** ✅ Fixed and tested

View File

@@ -1,319 +0,0 @@
# API App Pinning Implementation - Complete
## Overview
Successfully implemented app pinning functionality in the API (`api.py`) using Option 1 - extending the existing PATCH endpoint.
## Implementation Summary
### What Was Added
Added `set_pinned` operation to the `handle_patch_device_app()` endpoint at line 287-316 in `tronbyt_server/api.py`.
### Code Changes
**File: `tronbyt_server/api.py`**
Added the following code block after the `set_enabled` handler:
```python
# Handle the set_pinned json command
elif request.json is not None and "set_pinned" in request.json:
set_pinned = request.json["set_pinned"]
if not isinstance(set_pinned, bool):
return Response(
"Invalid value for set_pinned. Must be a boolean.", status=400
)
# Get user for saving changes
user = db.get_user_by_device_id(device_id)
if not user:
abort(HTTPStatus.NOT_FOUND, description="User not found")
apps = device.get("apps", {})
if installation_id not in apps:
abort(HTTPStatus.NOT_FOUND, description="App not found")
if set_pinned:
# Pin the app
device["pinned_app"] = installation_id
db.save_user(user)
return Response("App pinned.", status=200)
else:
# Unpin the app (only if it's currently pinned)
if device.get("pinned_app") == installation_id:
device.pop("pinned_app", None)
db.save_user(user)
return Response("App unpinned.", status=200)
else:
return Response("App is not pinned.", status=200)
```
### Key Features
1. **Validation**: Checks that `set_pinned` is a boolean value
2. **Authentication**: Uses existing API key authentication
3. **Authorization**: Verifies user owns the device
4. **Safety**: Sanitizes installation_id to prevent path traversal
5. **Persistence**: Saves changes via `db.save_user(user)`
6. **Consistency**: Matches the web interface behavior
## API Usage
### Endpoint
```
PATCH /v0/devices/{device_id}/installations/{installation_id}
```
### Authentication
```
Authorization: Bearer YOUR_API_KEY
```
### Pin an App
**Request:**
```bash
curl -X PATCH \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"set_pinned": true}' \
https://your-server.com/v0/devices/DEVICE_ID/installations/INSTALLATION_ID
```
**Response (Success):**
```
HTTP/1.1 200 OK
App pinned.
```
### Unpin an App
**Request:**
```bash
curl -X PATCH \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"set_pinned": false}' \
https://your-server.com/v0/devices/DEVICE_ID/installations/INSTALLATION_ID
```
**Response (Success):**
```
HTTP/1.1 200 OK
App unpinned.
```
**Response (App Not Pinned):**
```
HTTP/1.1 200 OK
App is not pinned.
```
## Error Responses
### Invalid Boolean Value
```
HTTP/1.1 400 Bad Request
Invalid value for set_pinned. Must be a boolean.
```
### Missing Authorization
```
HTTP/1.1 400 Bad Request
Missing or invalid Authorization header
```
### Invalid API Key
```
HTTP/1.1 404 Not Found
```
### App Not Found
```
HTTP/1.1 404 Not Found
App not found
```
### User Not Found
```
HTTP/1.1 404 Not Found
User not found
```
## How It Works
### Pin Operation (`set_pinned: true`)
1. Validates the request and authentication
2. Checks that the app exists in the device's app list
3. Sets `device["pinned_app"] = installation_id`
4. Saves the user data to persist the change
5. Returns success message
### Unpin Operation (`set_pinned: false`)
1. Validates the request and authentication
2. Checks if the app is currently pinned
3. If pinned, removes the `pinned_app` field from device
4. Saves the user data to persist the change
5. Returns appropriate message
### Behavior Notes
- Only one app can be pinned at a time
- Pinning a new app automatically replaces any previously pinned app
- Unpinning only works if the specified app is currently pinned
- Pinned apps bypass normal rotation and are always displayed
- Pinned apps are shown regardless of enabled/schedule status
## Integration with Existing System
### Data Storage
- Uses existing `device["pinned_app"]` field (already defined in `models/device.py`)
- No database schema changes required
- Stored as part of the user's device configuration
### Display Logic
The existing display logic in `manager.py` (lines 1254-1286) already handles pinned apps:
```python
# Check for pinned app first - this short-circuits all other app selection logic
pinned_app_iname = device.get("pinned_app")
is_pinned_app = False
if pinned_app_iname and pinned_app_iname in apps:
current_app.logger.debug(f"Using pinned app: {pinned_app_iname}")
app = apps[pinned_app_iname]
is_pinned_app = True
# For pinned apps, we don't update last_app_index since we're not cycling
else:
# Normal app selection logic
...
```
### Consistency with Web Interface
The API implementation matches the web interface behavior:
- Same data structure (`device["pinned_app"]`)
- Same persistence mechanism (`db.save_user(user)`)
- Same toggle logic (pin/unpin)
- Same priority (pinned apps always display)
## Testing Checklist
- [x] Code implemented
- [ ] Pin an app via API
- [ ] Verify app is pinned in web interface
- [ ] Verify pinned app displays on device
- [ ] Unpin app via API
- [ ] Verify app is unpinned in web interface
- [ ] Pin different app (verify old pin is replaced)
- [ ] Try to pin non-existent app (verify 404)
- [ ] Try with invalid API key (verify 404)
- [ ] Try with invalid boolean value (verify 400)
- [ ] Verify pinned app bypasses rotation
- [ ] Verify pinned app shows even when disabled
## Example Test Scenarios
### Scenario 1: Pin an App
```bash
# Pin app with installation_id "clock-123"
curl -X PATCH \
-H "Authorization: Bearer abc123" \
-H "Content-Type: application/json" \
-d '{"set_pinned": true}' \
http://localhost:5000/v0/devices/my-device/installations/clock-123
# Expected: "App pinned."
# Verify: Device always shows clock app
```
### Scenario 2: Switch Pinned App
```bash
# Pin a different app
curl -X PATCH \
-H "Authorization: Bearer abc123" \
-H "Content-Type: application/json" \
-d '{"set_pinned": true}' \
http://localhost:5000/v0/devices/my-device/installations/weather-456
# Expected: "App pinned."
# Verify: Device now shows weather app (clock is no longer pinned)
```
### Scenario 3: Unpin App
```bash
# Unpin the weather app
curl -X PATCH \
-H "Authorization: Bearer abc123" \
-H "Content-Type: application/json" \
-d '{"set_pinned": false}' \
http://localhost:5000/v0/devices/my-device/installations/weather-456
# Expected: "App unpinned."
# Verify: Device resumes normal app rotation
```
### Scenario 4: Error Handling
```bash
# Try to pin with invalid value
curl -X PATCH \
-H "Authorization: Bearer abc123" \
-H "Content-Type: application/json" \
-d '{"set_pinned": "yes"}' \
http://localhost:5000/v0/devices/my-device/installations/clock-123
# Expected: 400 Bad Request
# Message: "Invalid value for set_pinned. Must be a boolean."
```
## Files Modified
1. **`tronbyt_server/api.py`** (lines 287-316)
- Added `set_pinned` operation handler
- Integrated with existing PATCH endpoint
- Added user lookup for saving changes
## Related Files (No Changes Needed)
- **`tronbyt_server/models/device.py`** - Already has `pinned_app` field
- **`tronbyt_server/db.py`** - Uses existing `save_user()` function
- **`tronbyt_server/manager.py`** - Display logic already handles pinned apps
## Benefits
1. **RESTful**: Uses PATCH for partial resource updates
2. **Consistent**: Matches existing `set_enabled` pattern
3. **Simple**: No new endpoints or routes needed
4. **Secure**: Uses existing authentication and authorization
5. **Compatible**: Works with existing web interface
6. **Maintainable**: Clear, documented code
## Future Enhancements
Consider these optional improvements:
1. **Return pinned status in installation list:**
```python
"pinned": installation_id == device.get("pinned_app")
```
2. **Return pinned app in device payload:**
```python
"pinnedApp": device.get("pinned_app")
```
3. **Add JSON response with more details:**
```python
return Response(
json.dumps({"pinned": True, "message": "App pinned."}),
status=200,
mimetype="application/json"
)
```
## Documentation
- Full implementation guide: `API_APP_PINNING_IMPLEMENTATION.md`
- This completion summary: `API_PINNING_IMPLEMENTATION_COMPLETE.md`
## Status
**Implementation Complete**
The app pinning functionality is now available via the API and ready for testing!

View File

@@ -1,281 +0,0 @@
# Pinned App Notification Badge
## Overview
Added a visual notification badge next to the device name on the main manager page that displays which app is currently pinned.
## Implementation
### Location
The notification appears in the device header, right next to the device name on the main manager index page.
### Visual Design
**Badge Styling:**
- **Color**: Orange background (#ff9800) with white text
- **Icon**: 📌 (pin emoji/unicode &#128204;)
- **Text**: "Pinned: [App Name]"
- **Size**: 0.7em (smaller than device name)
- **Shape**: Rounded corners (4px border-radius)
- **Padding**: 4px top/bottom, 10px left/right
- **Position**: Inline with device name, 10px left margin
### Code Changes
**File: `tronbyt_server/templates/manager/index.html` (lines 343-351)**
```html
{% if device.get('pinned_app') %}
{% set pinned_app_iname = device.get('pinned_app') %}
{% set pinned_app = device.get('apps', {}).get(pinned_app_iname) %}
{% if pinned_app %}
<span style="display: inline-block; margin-left: 10px; padding: 4px 10px; background-color: #ff9800; color: white; border-radius: 4px; font-size: 0.7em; font-weight: bold; vertical-align: middle;">
&#128204; {{ _('Pinned:') }} {{ pinned_app['name'] }}
</span>
{% endif %}
{% endif %}
```
## How It Works
1. **Check for Pinned App**: `{% if device.get('pinned_app') %}`
- Only displays if a pinned app exists
2. **Get Installation ID**: `{% set pinned_app_iname = device.get('pinned_app') %}`
- Retrieves the pinned app's installation ID (iname)
3. **Lookup App Details**: `{% set pinned_app = device.get('apps', {}).get(pinned_app_iname) %}`
- Finds the full app object from the device's apps dictionary
4. **Display Badge**: Shows the app name with pin icon
- Only displays if the app object exists (safety check)
## Visual Examples
### Before (No Pinned App)
```
┌─────────────────────────────────────┐
│ My Device │
└─────────────────────────────────────┘
```
### After (With Pinned App)
```
┌─────────────────────────────────────────────────┐
│ My Device 📌 Pinned: Clock │
└─────────────────────────────────────────────────┘
```
### Multiple Devices
```
┌─────────────────────────────────────────────────┐
│ Living Room 📌 Pinned: Weather │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Bedroom │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Kitchen 📌 Pinned: Calendar │
└─────────────────────────────────────────────────┘
```
## Features
### 1. Visibility
- **Prominent**: Orange color stands out
- **Clear**: Pin icon immediately indicates purpose
- **Informative**: Shows exact app name that's pinned
### 2. Responsive
- **Inline**: Flows with device name
- **Scalable**: Font size relative to device name
- **Aligned**: Vertically centered with device name
### 3. Conditional
- **Only When Needed**: Only shows if app is pinned
- **Safe**: Checks if app exists before displaying
- **Clean**: No badge when no app is pinned
### 4. Internationalized
- **Translatable**: Uses `{{ _('Pinned:') }}` for i18n support
- **Universal**: Pin emoji works across languages
## User Experience Benefits
### Quick Identification
Users can instantly see:
- Which devices have pinned apps
- What app is pinned on each device
- Status at a glance without scrolling
### Visual Hierarchy
```
Device Name (Large, Bold)
└─ Pinned Badge (Smaller, Orange)
└─ App Name (Inside badge)
```
### Consistency
Matches the existing pinned indicator in the app list:
- Same orange color (#ff9800)
- Same pin icon (📌)
- Same "PINNED" terminology
## Technical Details
### CSS Styling
```css
display: inline-block; /* Allows padding and margins */
margin-left: 10px; /* Space from device name */
padding: 4px 10px; /* Internal spacing */
background-color: #ff9800; /* Orange background */
color: white; /* White text */
border-radius: 4px; /* Rounded corners */
font-size: 0.7em; /* Smaller than device name */
font-weight: bold; /* Bold text */
vertical-align: middle; /* Align with device name */
```
### Jinja2 Template Logic
- Uses `{% set %}` to create local variables
- Uses `.get()` for safe dictionary access
- Nested `{% if %}` for safety checks
- Translatable strings with `{{ _() }}`
### Data Flow
```
device["pinned_app"] = "clock-123"
device["apps"]["clock-123"] = {
"name": "Clock",
"iname": "clock-123",
...
}
Badge displays: "📌 Pinned: Clock"
```
## Edge Cases Handled
### 1. No Pinned App
```jinja2
{% if device.get('pinned_app') %}
```
- Badge doesn't appear if no app is pinned
### 2. Pinned App Deleted
```jinja2
{% if pinned_app %}
```
- Badge doesn't appear if pinned app no longer exists
- Prevents showing "Pinned: None" or errors
### 3. Empty Apps Dictionary
```jinja2
device.get('apps', {}).get(pinned_app_iname)
```
- Safely handles devices with no apps
## Integration with Existing Features
### Works With
- ✅ Pin/Unpin buttons in app list
- ✅ API pinning endpoint
- ✅ Web interface toggle_pin route
- ✅ Multiple devices
- ✅ All device types
### Complements
- App list "PINNED" indicator (line 422)
- Pin/Unpin action buttons (lines 458-473)
- Device display logic (manager.py)
## Testing Checklist
- [ ] Badge appears when app is pinned
- [ ] Badge shows correct app name
- [ ] Badge disappears when app is unpinned
- [ ] Badge doesn't appear for devices without pinned apps
- [ ] Badge handles deleted pinned apps gracefully
- [ ] Badge is properly aligned with device name
- [ ] Badge is readable on all screen sizes
- [ ] Badge text is translatable
- [ ] Pin icon displays correctly
- [ ] Multiple devices show correct badges
## Files Modified
1. **`tronbyt_server/templates/manager/index.html`** (lines 343-351)
- Added pinned app notification badge
- Integrated with device header
- Added safety checks for app existence
## Related Features
- **App List Indicator**: Shows "PINNED" status in app list (line 422)
- **Pin/Unpin Buttons**: Toggle pin status (lines 458-473)
- **API Endpoint**: `/v0/devices/{device_id}/installations/{installation_id}` with `set_pinned`
- **Web Route**: `toggle_pin()` in manager.py
## Future Enhancements
### Possible Improvements
1. **Clickable Badge**: Make badge link to the pinned app
```html
<a href="#app-{{ pinned_app['iname'] }}" style="...">
```
2. **Tooltip**: Show more info on hover
```html
title="This app is always displayed, bypassing rotation"
```
3. **Quick Unpin**: Add × button to unpin directly from badge
```html
<a href="{{ url_for('manager.toggle_pin', ...) }}" style="margin-left: 5px;">×</a>
```
4. **Animation**: Subtle pulse or glow effect
```css
animation: pulse 2s infinite;
```
5. **Icon Variation**: Different icons for different states
- 📌 Pinned
- 🔒 Locked
- ⭐ Featured
## Accessibility
- **Color Contrast**: White on orange (#ff9800) meets WCAG AA standards
- **Text Alternative**: Pin emoji has semantic meaning
- **Screen Readers**: Text "Pinned: [App Name]" is clear
- **Keyboard Navigation**: Badge is visible to all users
## Browser Compatibility
- **Modern Browsers**: Full support (Chrome, Firefox, Safari, Edge)
- **Pin Emoji**: Unicode &#128204; widely supported
- **CSS**: Standard properties, no vendor prefixes needed
- **Fallback**: If emoji doesn't render, text still clear
## Performance
- **Minimal Impact**: Simple conditional rendering
- **No JavaScript**: Pure HTML/CSS
- **No Extra Requests**: Uses existing data
- **Fast Rendering**: Inline styles, no external CSS
## Summary
Added a prominent, informative badge next to the device name that shows which app is currently pinned. The badge:
- Uses orange color and pin icon for visibility
- Shows the pinned app's name
- Only appears when an app is pinned
- Handles edge cases gracefully
- Matches existing design patterns
- Is fully internationalized
Users can now see at a glance which devices have pinned apps and what those apps are, without needing to scroll through the app list!

View File

@@ -1,187 +0,0 @@
# Uploaded App Delete Button Enhancement
## Overview
Enhanced the delete button for uploaded apps on the add app page to make it more prominent and user-friendly with better styling and confirmation dialog.
## Changes Made
### 1. Template Updates (`tronbyt_server/templates/manager/addapp.html`)
#### Enhanced Delete Link
Added styling, icon, and confirmation dialog:
```html
{% if is_custom_apps %}
<a href="{{ url_for('manager.deleteupload', filename=app['path'].split('/')[-1], device_id=device['id']) }}"
class="delete-upload-btn"
onclick="event.stopPropagation(); return confirm('{{ _('Delete this uploaded app?') }}');">
🗑️ {{ _('Delete') }}
</a>
{% endif %}
```
#### Updated Macro Signature
Added `is_custom_apps` parameter to the `render_app_list` macro:
```html
{% macro render_app_list(title, search_id, grid_id, app_list, show_controls=true, show_version=false, is_custom_apps=false) %}
```
#### Updated Macro Calls
Pass `is_custom_apps=true` for custom apps and `is_custom_apps=false` for system apps:
```html
{{ render_app_list(_('Custom Apps'), 'custom_search', 'custom_app_grid', custom_apps_list, show_controls=false, is_custom_apps=true) }}
{{ render_app_list(_('System Apps'), 'system_search', 'system_app_grid', apps_list, show_version=true, is_custom_apps=false) }}
```
#### Added CSS Styling
New styles for the delete button:
```css
.delete-upload-btn {
display: inline-block;
background-color: #f44336;
color: white;
padding: 5px 10px;
border-radius: 3px;
text-decoration: none;
font-size: 12px;
font-weight: bold;
margin-top: 8px;
transition: background-color 0.2s;
}
.delete-upload-btn:hover {
background-color: #d32f2f;
text-decoration: none;
}
```
## Features
### Visual Improvements
- **Red Button**: Prominent red background (#f44336) to indicate destructive action
- **Trash Icon**: 🗑️ emoji for visual clarity
- **Bold Text**: Makes the button stand out
- **Rounded Corners**: Modern, polished appearance
- **Hover Effect**: Darker red (#d32f2f) on hover for feedback
### Functional Improvements
- **Confirmation Dialog**: Asks "Delete this uploaded app?" before deletion
- **Event Propagation Stop**: `event.stopPropagation()` prevents app selection when clicking delete
- **Internationalized**: Uses `{{ _() }}` for translation support
### User Experience
- **Clear Intent**: Red color and trash icon clearly indicate deletion
- **Safety**: Confirmation dialog prevents accidental deletion
- **No Interference**: Clicking delete doesn't select the app
- **Responsive**: Hover effect provides visual feedback
## Before vs After
### Before
```
[App Preview]
App Name - Description
From Author
Delete ← plain text link
```
### After
```
[App Preview]
App Name - Description
From Author
┌─────────────┐
│ 🗑️ Delete │ ← red button with icon
└─────────────┘
```
## Technical Details
### Conditional Display
The delete button only appears for uploaded apps:
```python
{% if is_custom_apps %}
```
This uses a flag passed to the macro that explicitly indicates whether the app list contains custom/uploaded apps (not system apps). This is more reliable than checking the path string.
### Event Handling
```javascript
onclick="event.stopPropagation(); return confirm('...');"
```
1. `event.stopPropagation()` - Prevents the click from bubbling up to the app-item div (which would select the app)
2. `return confirm('...')` - Shows confirmation dialog; only proceeds if user clicks OK
### Existing Route
The delete functionality uses the existing `deleteupload` route:
```python
url_for('manager.deleteupload', filename=app['path'].split('/')[-1], device_id=device['id'])
```
## User Flow
1. User navigates to Add App page
2. User sees uploaded apps in the Custom Apps section
3. Each uploaded app shows a red "🗑️ Delete" button
4. User clicks the delete button
5. Confirmation dialog appears: "Delete this uploaded app?"
6. If user clicks OK:
- App is deleted from the server
- Page redirects back to Add App page
- App no longer appears in the list
7. If user clicks Cancel:
- Nothing happens
- App remains in the list
## Benefits
1. **Visibility**: Red button is much more noticeable than plain text link
2. **Safety**: Confirmation dialog prevents accidental deletion
3. **Clarity**: Trash icon makes the action obvious
4. **Professional**: Styled button looks polished and modern
5. **Consistent**: Matches the styling of other action buttons in the app
6. **Accessible**: Clear visual and textual indication of purpose
## Files Modified
1. `tronbyt_server/templates/manager/addapp.html`
- Enhanced delete link with class, icon, and confirmation
- Added CSS styling for delete button
## Testing
To test the feature:
1. Upload a .star file using the "Upload .star file" button
2. Navigate to the Add App page
3. Find the uploaded app in the Custom Apps section
4. Verify the red "🗑️ Delete" button appears
5. Click the delete button
6. Verify confirmation dialog appears
7. Click Cancel - verify nothing happens
8. Click the delete button again
9. Click OK - verify app is deleted and page refreshes
10. Verify the app no longer appears in the list
## Edge Cases Handled
- **Click Propagation**: Delete button click doesn't select the app
- **Confirmation**: User must confirm before deletion
- **System Apps**: Delete button only shows for uploaded apps, not system apps
- **Hover State**: Visual feedback when hovering over button
- **Text Decoration**: No underline on hover (common link issue)
## Future Enhancements
Possible future improvements:
- Add undo functionality
- Show toast notification after deletion
- Add bulk delete option
- Show file size next to delete button
- Add "last uploaded" date display
- Implement soft delete with recovery option

View File

@@ -1 +0,0 @@
"""Tronbyt Server application."""

View File

@@ -1,37 +0,0 @@
"""Application configuration."""
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
"""Application settings."""
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
SECRET_KEY: str = "lksdj;as987q3908475ukjhfgklauy983475iuhdfkjghairutyh"
USERS_DIR: str = "users"
DATA_DIR: str = "data"
PRODUCTION: str = "1"
DB_FILE: str = "users/usersdb.sqlite"
LANGUAGES: list[str] = ["en", "de"]
MAX_USERS: int = 100
ENABLE_USER_REGISTRATION: str = "0"
LOG_LEVEL: str = "INFO"
SYSTEM_APPS_REPO: str = "https://github.com/tronbyt/apps.git"
LIBPIXLET_PATH: str | None = None
REDIS_URL: str | None = None
GITHUB_TOKEN: str | None = None # Optional GitHub API token for higher rate limits
SINGLE_USER_AUTO_LOGIN: str = (
"0" # Auto-login when exactly 1 user exists (private networks only)
)
@lru_cache
def get_settings() -> Settings:
"""Return the settings object."""
return Settings()

File diff suppressed because it is too large Load Diff

View File

@@ -1,274 +0,0 @@
"""FastAPI dependencies."""
import ipaddress
import logging
import sqlite3
from datetime import timedelta
from typing import Generator
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.responses import RedirectResponse, Response
from fastapi_login import LoginManager
from fastapi_login.exceptions import InvalidCredentialsException
from tronbyt_server import db
from tronbyt_server.config import Settings, get_settings
from tronbyt_server.models import App, Device, User, DeviceID
logger = logging.getLogger(__name__)
class NotAuthenticatedException(Exception):
"""Exception for when a user is not authenticated."""
pass
manager = LoginManager(
get_settings().SECRET_KEY,
"/auth/login",
use_cookie=True,
not_authenticated_exception=NotAuthenticatedException,
)
def get_db(
settings: Settings = Depends(get_settings),
) -> Generator[sqlite3.Connection, None, None]:
"""Get a database connection."""
db_conn = sqlite3.connect(settings.DB_FILE, check_same_thread=False)
with db_conn:
yield db_conn
class UserAndDevice:
"""Container for user and device objects."""
def __init__(self, user: User, device: Device):
"""Initialize the UserAndDevice object."""
self.user = user
self.device = device
class DeviceAndApp:
"""Container for device and app objects."""
def __init__(self, device: Device, app: App):
"""Initialize the DeviceAndApp object."""
self.device = device
self.app = app
def get_device_and_app(
device_id: DeviceID,
iname: str,
user: User = Depends(manager),
) -> DeviceAndApp:
"""Get a device and app from a device ID and app iname."""
device = user.devices.get(device_id)
if not device:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Device not found"
)
app = device.apps.get(iname)
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="App not found"
)
return DeviceAndApp(device, app)
def get_user_and_device(
device_id: DeviceID, db_conn: sqlite3.Connection = Depends(get_db)
) -> UserAndDevice:
"""Get a user and device from a device ID."""
user = db.get_user_by_device_id(db_conn, device_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
device = user.devices.get(device_id)
if not device:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Device not found"
)
return UserAndDevice(user, device)
def check_for_users(
request: Request, db_conn: sqlite3.Connection = Depends(get_db)
) -> None:
"""Check if there are any users in the database."""
if not db.has_users(db_conn):
if request.url.path != "/auth/register_owner":
raise NotAuthenticatedException
def get_user_and_device_from_api_key(
device_id: str | None = None,
authorization: str | None = Header(None, alias="Authorization"),
db_conn: sqlite3.Connection = Depends(get_db),
) -> tuple[User | None, Device | None]:
"""Get a user and/or device from an API key."""
if not authorization:
raise InvalidCredentialsException
api_key = (
authorization.split(" ")[1]
if authorization.startswith("Bearer ")
else authorization
)
user = db.get_user_by_api_key(db_conn, api_key)
if user:
device = user.devices.get(device_id) if device_id else None
return user, device
device = db.get_device_by_id(db_conn, device_id) if device_id else None
if device and device.api_key == api_key:
user = db.get_user_by_device_id(db_conn, device.id)
return user, device
raise InvalidCredentialsException
@manager.user_loader() # type: ignore
def load_user(username: str) -> User | None:
"""Load a user from the database."""
with next(get_db(settings=get_settings())) as db_conn:
user = db.get_user(db_conn, username)
if user:
return user
return None
def is_trusted_network(client_host: str | None) -> bool:
"""
Check if the client is from a trusted network (localhost or private networks).
Trusted networks include:
- IPv4 localhost: 127.0.0.1
- IPv6 localhost: ::1
- IPv4 private networks: 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12
- IPv6 unique local addresses (ULA): fc00::/7
- IPv6 link-local addresses: fe80::/10
- (deprecated) IPv6 site-local: fec0::/10
"""
if not client_host:
return False
# Check for localhost strings
if client_host in ("127.0.0.1", "localhost", "::1"):
return True
try:
# Parse the IP address
ip = ipaddress.ip_address(client_host)
# Check if it's a private network address (RFC1918)
# This covers: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
if ip.is_private:
return True
# Also check for loopback explicitly
if ip.is_loopback:
return True
except ValueError:
# Invalid IP address format
return False
return False
def is_auto_login_active(db_conn: sqlite3.Connection | None = None) -> bool:
"""
Check if auto-login is truly active.
Auto-login is active when:
- SINGLE_USER_AUTO_LOGIN setting is "1"
- AND exactly 1 user exists in the system
Args:
db_conn: Optional database connection. If not provided, creates one.
Returns:
True if auto-login is active, False otherwise.
"""
settings = get_settings()
if settings.SINGLE_USER_AUTO_LOGIN != "1":
return False
# Check user count
try:
if db_conn is None:
db_conn = sqlite3.connect(settings.DB_FILE, check_same_thread=False)
should_close = True
else:
should_close = False
with db_conn:
users = db.get_all_users(db_conn)
result = len(users) == 1
if should_close:
db_conn.close()
return result
except Exception:
return False
def auth_exception_handler(
request: Request, exc: NotAuthenticatedException
) -> Response:
"""
Redirect the user to the login page if not logged in.
Special case: If auto-login is active and the request is from a trusted network,
automatically log in the single user.
"""
settings = get_settings()
with next(get_db(settings=settings)) as db_conn:
# No users exist - redirect to registration
if not db.has_users(db_conn):
return RedirectResponse(request.url_for("get_register_owner"))
# Check for single-user auto-login mode
if is_auto_login_active(db_conn):
# Only from trusted networks (localhost or private networks)
client_host = request.client.host if request.client else None
if is_trusted_network(client_host):
# Get the single user
users = db.get_all_users(db_conn)
user = users[0]
logger.warning(
f"Single-user auto-login: Logging in as '{user.username}' "
f"from {client_host}"
)
# Create access token (30 day expiration for convenience)
access_token = manager.create_access_token(
data={"sub": user.username}, expires=timedelta(days=30)
)
# Redirect to home page with cookie set
response = RedirectResponse(
request.url_for("index"), status_code=status.HTTP_302_FOUND
)
response.set_cookie(
key=manager.cookie_name,
value=access_token,
max_age=30 * 24 * 60 * 60, # 30 days
httponly=True,
samesite="lax",
)
return response
# Default: redirect to login
return RedirectResponse(request.url_for("login"))

View File

@@ -1,90 +0,0 @@
import io
import struct
import sys
from esptool.bin_image import ESP32FirmwareImage, LoadFirmwareImage
def get_chip_config(device_type: str) -> str:
"""Return chip type based on device type."""
if device_type in [
"tronbyt_s3",
"matrixportal_s3",
"matrixportal_s3_waveshare",
"tronbyt_s3_wide",
]:
return "esp32s3"
return "esp32"
def update_firmware_data(data: bytes, device_type: str = "esp32") -> bytes:
chip_type = get_chip_config(device_type)
try:
image = LoadFirmwareImage(chip=chip_type, image_data=data)
except Exception as e:
raise ValueError(f"Error loading firmware image: {e}")
if not isinstance(image, ESP32FirmwareImage):
raise ValueError(f"Unsupported image type: {type(image).__name__}")
if image.stored_digest is None:
raise ValueError("Failed to parse firmware data: did not find validation hash")
print(f"Chip type: {chip_type}")
print(f"Original checksum: {image.checksum:02x}")
print(f"Original SHA256: {image.stored_digest.hex()}")
new_checksum = image.calculate_checksum()
if new_checksum is None:
raise ValueError("Failed to calculate new checksum")
# Update the checksum directly in the buffer
buffer = io.BytesIO(data)
buffer.seek(-33, 2) # Write the checksum at position 33 from the end
buffer.write(struct.pack("B", new_checksum))
print(f"Updated data with checksum {new_checksum:02x}.")
# Recalculate the SHA256
try:
image = LoadFirmwareImage(chip=chip_type, image_data=buffer.getvalue())
except Exception as e:
raise ValueError(f"Error loading new firmware image: {e}")
if not isinstance(image, ESP32FirmwareImage):
raise ValueError(f"Unsupported image type: {type(image).__name__}")
if not image.calc_digest:
raise ValueError(
"Failed to parse firmware data: did not find validation hash after fixing the checksum"
)
# Update the SHA256 directly in the buffer
buffer.seek(-32, 2) # Write the SHA256 hash at the end
buffer.write(image.calc_digest)
print(f"Updated data with SHA256 {image.calc_digest.hex()}.")
return buffer.getvalue()
def main() -> None:
file_path = sys.argv[1]
try:
# Read the file contents
with open(file_path, "rb") as f:
data = f.read()
# Update the firmware data
updated_data = update_firmware_data(data)
# Write the updated data back to the file
with open(file_path, "wb") as f:
f.write(updated_data)
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,326 +0,0 @@
"""Utilities for generating, modifying, and downloading firmware binaries."""
import json
import os
import requests
import subprocess
import sys
import logging
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from tronbyt_server import db
from tronbyt_server.config import get_settings
from tronbyt_server.firmware import correct_firmware_esptool
logger = logging.getLogger(__name__)
# firmware bin files named after env targets in firmware project.
def generate_firmware(
url: str,
ap: str,
pw: str,
device_type: str,
swap_colors: bool,
) -> bytes:
# Determine the firmware filename based on device type
if device_type == "tidbyt_gen2":
firmware_filename = "tidbyt-gen2.bin"
elif device_type == "pixoticker":
firmware_filename = "pixoticker.bin"
elif device_type == "tronbyt_s3":
firmware_filename = "tronbyt-S3.bin"
elif device_type == "tronbyt_s3_wide":
firmware_filename = "tronbyt-s3-wide.bin"
elif device_type == "matrixportal_s3":
firmware_filename = "matrixportal-s3.bin"
elif device_type == "matrixportal_s3_waveshare":
firmware_filename = "matrixportal-s3-waveshare.bin"
elif swap_colors:
firmware_filename = "tidbyt-gen1_swap.bin"
else:
firmware_filename = "tidbyt-gen1.bin"
# Check data directory first (for downloaded firmware), then fallback to bundled firmware
data_firmware_path = db.get_data_dir() / "firmware" / firmware_filename
bundled_firmware_path = Path(__file__).parent / "firmware" / firmware_filename
if data_firmware_path.exists():
file_path = data_firmware_path
elif bundled_firmware_path.exists():
file_path = bundled_firmware_path
else:
raise ValueError(
f"Firmware file {firmware_filename} not found in {data_firmware_path} or {bundled_firmware_path}."
)
dict = {
"XplaceholderWIFISSID____________": ap,
"XplaceholderWIFIPASSWORD________________________________________": pw,
"XplaceholderREMOTEURL___________________________________________________________________________________________________________": url,
}
with file_path.open("rb") as f:
content = f.read()
for old_string, new_string in dict.items():
if len(new_string) > len(old_string):
raise ValueError(
"Replacement string cannot be longer than the original string."
)
position = content.find(old_string.encode("ascii") + b"\x00")
if position == -1:
raise ValueError(f"String '{old_string}' not found in the binary.")
padded_new_string = new_string + "\x00"
padded_new_string = padded_new_string.ljust(len(old_string) + 1, "\x00")
content = (
content[:position]
+ padded_new_string.encode("ascii")
+ content[position + len(old_string) + 1 :]
)
try:
return correct_firmware_esptool.update_firmware_data(content, device_type)
except ValueError:
# For testing with dummy firmware, skip correction
return content
def update_firmware_binaries(base_path: Path) -> dict[str, Any]:
"""Download the latest firmware bin files from GitHub releases.
Returns:
dict: Status information with keys:
- 'success': bool - Whether the operation completed successfully
- 'action': str - What action was taken ('updated', 'skipped', 'error')
- 'message': str - Human readable message
- 'version': str - Version that was processed
- 'files_downloaded': int - Number of files downloaded (0 if skipped)
"""
firmware_path = base_path / "firmware"
firmware_repo = os.environ.get(
"FIRMWARE_REPO", "https://github.com/tronbyt/firmware-esp32"
)
# Ensure firmware directory exists
firmware_path.mkdir(parents=True, exist_ok=True)
# Extract owner and repo from URL
if firmware_repo.endswith(".git"):
firmware_repo = firmware_repo[:-4] # Remove .git suffix
# Parse GitHub URL to get owner/repo
try:
parsed_url = urlparse(firmware_repo)
if parsed_url.netloc == "github.com":
# Remove leading/trailing '/' and split the path
parts = [seg for seg in parsed_url.path.strip("/").split("/") if seg]
if len(parts) >= 2:
owner, repo = parts[0], parts[1]
else:
raise ValueError("Invalid GitHub URL format")
else:
raise ValueError("Not a GitHub URL")
except Exception as e:
error_msg = f"Error parsing firmware repository URL {firmware_repo}: {e}"
logger.info(error_msg)
return {
"success": False,
"action": "error",
"message": error_msg,
"version": "unknown",
"files_downloaded": 0,
}
# GitHub API URL for latest release
api_url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
# Optional GitHub API token
settings = get_settings()
github_token = settings.GITHUB_TOKEN
try:
logger.info(f"Fetching latest release info from {api_url}")
github_api_headers = {"Accept": "application/vnd.github+json"}
github_download_headers = {}
if github_token:
logger.info("Using provided GitHub token")
auth_header = f"Bearer {github_token}"
github_api_headers["Authorization"] = auth_header
github_download_headers["Authorization"] = auth_header
# Fetch release information
response = requests.get(api_url, headers=github_api_headers, timeout=10)
response.raise_for_status()
release_data = response.json()
release_tag = release_data.get("tag_name", "unknown")
logger.info(f"Found latest release: {release_tag}")
# Check if we already have this version
version_file = firmware_path / "firmware_version.txt"
current_version = None
if version_file.exists():
try:
with version_file.open("r") as f:
current_version = f.read().strip()
logger.info(f"Current firmware version: {current_version}")
except Exception as e:
logger.info(f"Error reading current version file: {e}")
if current_version == release_tag:
logger.info(f"Firmware is already up to date (version {release_tag})")
return {
"success": True,
"action": "skipped",
"message": f"Firmware is already up to date (version {release_tag})",
"version": release_tag,
"files_downloaded": 0,
}
# Mapping from GitHub release names to expected local names
firmware_name_mapping = {
"tidbyt-gen1_firmware.bin": "tidbyt-gen1.bin",
"tidbyt-gen1_swap_firmware.bin": "tidbyt-gen1_swap.bin",
"tidbyt-gen2_firmware.bin": "tidbyt-gen2.bin",
"pixoticker_firmware.bin": "pixoticker.bin",
"tronbyt-s3_firmware.bin": "tronbyt-S3.bin",
"tronbyt-s3-wide_firmware.bin": "tronbyt-s3-wide.bin",
"matrixportal-s3_firmware.bin": "matrixportal-s3.bin",
"matrixportal-s3-waveshare_firmware.bin": "matrixportal-s3-waveshare.bin",
}
# Download all .bin files from the release assets
assets = release_data.get("assets", [])
bin_files_downloaded = 0
for asset in assets:
asset_name = asset.get("name", "")
if asset_name.endswith(".bin"):
download_url = asset.get("browser_download_url")
if download_url and asset_name in firmware_name_mapping:
# Use mapped name if available, otherwise use original name
local_name = firmware_name_mapping.get(asset_name, asset_name)
dest_file = firmware_path / str(local_name)
logger.info(
f"Downloading firmware file: {asset_name} -> {local_name}"
)
try:
r = requests.get(
download_url, headers=github_download_headers, timeout=300
)
r.raise_for_status()
dest_file.write_bytes(r.content)
bin_files_downloaded += 1
logger.info(f"Successfully downloaded: {local_name}")
except Exception as e:
logger.info(f"Error downloading {asset_name}: {e}")
if bin_files_downloaded > 0:
logger.info(
f"Downloaded {bin_files_downloaded} firmware files to {firmware_path}"
)
# Write version information to file
version_file = firmware_path / "firmware_version.txt"
try:
with version_file.open("w") as f:
f.write(f"{release_tag}\n")
logger.info(f"Saved firmware version {release_tag} to {version_file}")
return {
"success": True,
"action": "updated",
"message": f"Successfully updated firmware to version {release_tag} ({bin_files_downloaded} files downloaded)",
"version": release_tag,
"files_downloaded": bin_files_downloaded,
}
except Exception as e:
logger.info(f"Error writing version file: {e}")
return {
"success": False,
"action": "error",
"message": f"Downloaded firmware but failed to save version file: {e}",
"version": release_tag,
"files_downloaded": bin_files_downloaded,
}
else:
logger.info("No .bin files found in the latest release")
return {
"success": False,
"action": "error",
"message": "No firmware files found in the latest release",
"version": release_tag,
"files_downloaded": 0,
}
except requests.exceptions.RequestException as e:
error_msg = f"Error fetching release info: {e}"
logger.info(error_msg)
return {
"success": False,
"action": "error",
"message": error_msg,
"version": "unknown",
"files_downloaded": 0,
}
except json.JSONDecodeError as e:
error_msg = f"Error parsing release JSON: {e}"
logger.info(error_msg)
return {
"success": False,
"action": "error",
"message": error_msg,
"version": "unknown",
"files_downloaded": 0,
}
except Exception as e:
error_msg = f"Error updating firmware: {e}"
logger.info(error_msg)
return {
"success": False,
"action": "error",
"message": error_msg,
"version": "unknown",
"files_downloaded": 0,
}
def update_firmware_binaries_subprocess(
base_path: Path,
) -> dict[str, Any]:
# Run the update_firmware_binaries function in a subprocess.
# This is a workaround for a conflict between Python's and Go's TLS implementations.
# Only one TLS stack can be used per process, and libpixlet (used for rendering apps)
# uses Go's TLS stack, which conflicts with Python's requests library that uses
# Python's TLS stack.
# See: https://github.com/tronbyt/server/issues/344.
# Run the update in a subprocess and capture stdout for result transfer
result = subprocess.run(
[
sys.executable,
"-c",
(
"from tronbyt_server import firmware_utils; "
"import logging, sys, json; "
"from pathlib import Path;"
"result = firmware_utils.update_firmware_binaries(Path(sys.argv[1])); "
"print(json.dumps(result))"
),
str(base_path),
],
check=True,
capture_output=True,
text=True,
)
if result.stderr:
logger.info(f"Firmware update subprocess logs:\n{result.stderr.strip()}")
# Parse and return the result from stdout
return dict(json.loads(result.stdout))

View File

@@ -1,18 +0,0 @@
"""Flash message utility."""
from typing import cast
from fastapi import Request
def flash(request: Request, message: str, category: str = "primary") -> None:
"""Store a message in the session to be displayed later."""
if "_messages" not in request.session:
request.session["_messages"] = []
messages = cast(list[dict[str, str]], request.session["_messages"])
messages.append({"message": message, "category": category})
def get_flashed_messages(request: Request) -> list[dict[str, str]]:
"""Retrieve and clear flashed messages from the session."""
messages = request.session.pop("_messages", [])
return cast(list[dict[str, str]], messages)

View File

@@ -1,32 +0,0 @@
"""Git utilities using GitPython."""
import logging
from pathlib import Path
from git import (
GitCommandError,
InvalidGitRepositoryError,
NoSuchPathError,
Remote,
Repo,
)
logger = logging.getLogger(__name__)
def get_repo(path: Path) -> Repo | None:
"""Get a GitPython Repo object for the given path."""
try:
return Repo(path)
except (InvalidGitRepositoryError, NoSuchPathError, GitCommandError):
return None
def get_primary_remote(repo: Repo) -> Remote | None:
"""Gets the 'origin' remote, or the first remote as a fallback."""
if not repo.remotes:
return None
try:
return repo.remotes.origin
except AttributeError:
return repo.remotes[0]

View File

@@ -1,25 +0,0 @@
import sys
import requests
def health_check(url: str) -> bool:
try:
response = requests.get(url)
if response.status_code == 200:
return True
else:
return False
except requests.exceptions.RequestException as e:
print(f"Failed: {e}")
return False
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python -m tronbyt_server.healthcheck <URL>", file=sys.stderr)
sys.exit(1)
url: str = sys.argv[1]
result: bool = health_check(url)
sys.exit(0 if result else 1)

View File

@@ -1,55 +0,0 @@
"""Main application file."""
import logging
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from fastapi_babel import Babel, BabelConfigs, BabelMiddleware
from starlette.middleware.sessions import SessionMiddleware
from tronbyt_server.config import get_settings
from tronbyt_server.dependencies import (
NotAuthenticatedException,
auth_exception_handler,
)
from tronbyt_server.routers import api, auth, manager, websockets
from tronbyt_server.templates import templates
MODULE_ROOT = Path(__file__).parent.resolve()
logger = logging.getLogger(__name__)
app = FastAPI()
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(SessionMiddleware, secret_key=get_settings().SECRET_KEY)
# Babel configuration
babel_configs = BabelConfigs(
ROOT_DIR=MODULE_ROOT.parent,
BABEL_DEFAULT_LOCALE="en",
BABEL_TRANSLATION_DIRECTORY=str(MODULE_ROOT / "translations"),
)
app.add_middleware(
BabelMiddleware, babel_configs=babel_configs, jinja2_templates=templates
)
Babel(configs=babel_configs)
@app.exception_handler(NotAuthenticatedException)
def handle_auth_exception(request: Request, exc: NotAuthenticatedException) -> Response:
"""Redirect the user to the login page if not logged in."""
return auth_exception_handler(request, exc)
app.mount("/static", StaticFiles(directory=MODULE_ROOT / "static"), name="static")
app.include_router(api.router)
app.include_router(auth.router)
app.include_router(manager.router)
app.include_router(websockets.router)

View File

@@ -1,70 +0,0 @@
"""Models for the application."""
from .app import (
App,
AppMetadata,
RecurrencePattern,
RecurrenceType,
Weekday,
ColorFilter,
COLOR_FILTER_CHOICES,
)
from .device import (
DEFAULT_DEVICE_TYPE,
Device,
DeviceID,
DeviceInfo,
DeviceType,
DEVICE_TYPE_CHOICES,
Location,
ProtocolType,
Brightness,
parse_custom_brightness_scale,
)
from .user import User, ThemePreference
from .ws import (
ClientInfo,
ClientInfoMessage,
ClientMessage,
DisplayingMessage,
DisplayingStatusMessage,
QueuedMessage,
DwellSecsMessage,
BrightnessMessage,
ImmediateMessage,
StatusMessage,
ServerMessage,
)
__all__ = [
"App",
"AppMetadata",
"Device",
"DeviceInfo",
"User",
"DeviceID",
"RecurrencePattern",
"RecurrenceType",
"Weekday",
"DEFAULT_DEVICE_TYPE",
"DeviceType",
"DEVICE_TYPE_CHOICES",
"Location",
"ThemePreference",
"ClientInfo",
"ClientInfoMessage",
"ClientMessage",
"DisplayingMessage",
"DisplayingStatusMessage",
"QueuedMessage",
"DwellSecsMessage",
"BrightnessMessage",
"ImmediateMessage",
"StatusMessage",
"ServerMessage",
"ProtocolType",
"Brightness",
"parse_custom_brightness_scale",
"COLOR_FILTER_CHOICES",
"ColorFilter",
]

View File

@@ -1,170 +0,0 @@
"""Data models for Tronbyt Server applications."""
from enum import Enum
from typing import Any, Self
from pydantic import BaseModel, Field, BeforeValidator, ConfigDict
from datetime import time, date, timedelta
from typing import Annotated
class Weekday(str, Enum):
"""Weekday enumeration."""
MONDAY = "monday"
TUESDAY = "tuesday"
WEDNESDAY = "wednesday"
THURSDAY = "thursday"
FRIDAY = "friday"
SATURDAY = "saturday"
SUNDAY = "sunday"
def parse_time(v: Any) -> Any:
"""Parse time from string."""
if isinstance(v, str):
try:
return time.fromisoformat(v)
except ValueError:
return None
return v
def parse_date_optional(v: Any) -> Any:
"""Parse date from string, allowing empty string as None."""
if v == "":
return None
return v
class RecurrencePattern(BaseModel):
"""Recurrence pattern for monthly/yearly schedules."""
day_of_month: int | None = None # 1-31 for specific day of month
day_of_week: str | None = (
None # "first_monday", "second_tuesday", "last_friday", etc.
)
weekdays: list[Weekday] | None = (
None # For weekly patterns: ["monday", "wednesday"]
)
class RecurrenceType(str, Enum):
"""Recurrence type enumeration."""
DAILY = "daily"
WEEKLY = "weekly"
MONTHLY = "monthly"
YEARLY = "yearly"
def _(s: str) -> str:
return s
class ColorFilter(str, Enum):
"""Color filter enumeration."""
display_name: str
def __new__(cls, value: str, display_name: str) -> Self:
obj = str.__new__(cls, value)
obj._value_ = value
obj.display_name = display_name
return obj
@classmethod
def _missing_(cls, value: object) -> Any:
if value == "":
return cls.NONE
return super()._missing_(value)
INHERIT = ("inherit", _("Use Device Settings"))
NONE = ("none", _("None"))
DIMMED = ("dimmed", _("Dimmed"))
REDSHIFT = ("redshift", _("Redshift"))
WARM = ("warm", _("Warm"))
SUNSET = ("sunset", _("Sunset"))
SEPIA = ("sepia", _("Sepia"))
VINTAGE = ("vintage", _("Vintage"))
DUSK = ("dusk", _("Dusk"))
COOL = ("cool", _("Cool"))
BW = ("bw", _("Black & White"))
ICE = ("ice", _("Ice"))
MOONLIGHT = ("moonlight", _("Moonlight"))
NEON = ("neon", _("Neon"))
PASTEL = ("pastel", _("Pastel"))
COLOR_FILTER_CHOICES = {member.value: member.display_name for member in ColorFilter}
class App(BaseModel):
"""Pydantic model for an app."""
model_config = ConfigDict(validate_assignment=True)
id: str | None = None
iname: str
name: str
uinterval: int = 0 # Update interval for the app
display_time: int = 0 # Display time for the app
notes: str = "" # User notes for the app
enabled: bool = True
pushed: bool = False
order: int = 0 # Order in the app list
last_render: int = 0
last_render_duration: timedelta = timedelta(seconds=0)
path: str | None = None # Path to the app file
start_time: Annotated[time | None, BeforeValidator(parse_time)] = (
None # Optional start time (HH:MM)
)
end_time: Annotated[time | None, BeforeValidator(parse_time)] = (
None # Optional end time (HH:MM)
)
days: list[Weekday] = []
# Custom recurrence system (opt-in)
use_custom_recurrence: bool = (
False # Flag to enable custom recurrence instead of legacy
)
recurrence_type: RecurrenceType = Field(
default=RecurrenceType.DAILY,
description='"daily", "weekly", "monthly", "yearly"',
)
recurrence_interval: int = 1 # Every X weeks/months/years
recurrence_pattern: RecurrencePattern = Field(default_factory=RecurrencePattern)
recurrence_start_date: Annotated[
date | None, BeforeValidator(parse_date_optional)
] = None # ISO date string for calculating cycles (YYYY-MM-DD)
recurrence_end_date: Annotated[
date | None, BeforeValidator(parse_date_optional)
] = None # Optional end date for recurrence (YYYY-MM-DD)
config: dict[str, Any] = {}
empty_last_render: bool = False
render_messages: list[str] = [] # Changed from str to List[str]
autopin: bool = False
color_filter: ColorFilter | None = ColorFilter.INHERIT
class AppMetadata(BaseModel):
"""Pydantic model for app metadata."""
id: str | None = None
name: str
summary: str = ""
desc: str = ""
author: str = ""
path: str
file_name: str | None = Field(default=None, alias="fileName")
package_name: str | None = Field(default=None, alias="packageName")
preview: str | None = None
preview2x: str | None = None
supports2x: bool = False
recommended_interval: int = Field(default=0, alias="recommendedInterval")
date: str = "" # ISO date string for file modification date
is_installed: bool = False # Used to mark if app is installed on any device
uinterval: int = 0 # Update interval for the app
display_time: int = 0 # Display time for the app
notes: str = "" # User notes for the app
order: int = 0 # Order in the app list
broken: bool = False
brokenReason: str | None = None

View File

@@ -1,349 +0,0 @@
"""Data models and validation functions for devices in Tronbyt Server."""
from datetime import datetime
from enum import Enum
from typing import Annotated, Any, Self
from zoneinfo import ZoneInfo
import functools
from pydantic import (
BaseModel,
Field,
AfterValidator,
BeforeValidator,
AliasChoices,
GetCoreSchemaHandler,
ConfigDict,
)
from pydantic_core import core_schema
from .app import App, ColorFilter
class DeviceType(str, Enum):
"""Device type enumeration."""
display_name: str
def __new__(cls, value: str, display_name: str) -> Self:
obj = str.__new__(cls, value)
obj._value_ = value
obj.display_name = display_name
return obj
TIDBYT_GEN1 = ("tidbyt_gen1", "Tidbyt Gen1")
TIDBYT_GEN2 = ("tidbyt_gen2", "Tidbyt Gen2")
PIXOTICKER = ("pixoticker", "Pixoticker")
RASPBERRYPI = ("raspberrypi", "Raspberry Pi")
RASPBERRYPI_WIDE = ("raspberrypi_wide", "Raspberry Pi Wide")
TRONBYT_S3 = ("tronbyt_s3", "Tronbyt S3")
TRONBYT_S3_WIDE = ("tronbyt_s3_wide", "Tronbyt S3 Wide")
MATRIXPORTAL = ("matrixportal_s3", "MatrixPortal S3")
MATRIXPORTAL_S3_WAVESHARE = (
"matrixportal_s3_waveshare",
"MatrixPortal S3 Waveshare",
)
OTHER = ("other", "Other")
DEFAULT_DEVICE_TYPE: DeviceType = DeviceType.TIDBYT_GEN1
TWO_X_CAPABLE_DEVICE_TYPES = (DeviceType.TRONBYT_S3_WIDE, DeviceType.RASPBERRYPI_WIDE)
DEVICE_TYPE_CHOICES = {member.value: member.display_name for member in DeviceType}
DeviceID = Annotated[str, Field(pattern=r"^[a-fA-F0-9]{8}$")]
@functools.total_ordering
class Brightness:
"""A type for representing brightness, handling conversions between percentage, 8-bit, and UI scale."""
def __init__(self, value: int):
if not 0 <= value <= 100:
raise ValueError("Brightness must be a percentage between 0 and 100")
self.value = value
@property
def as_percent(self) -> int:
"""Return brightness as a percentage (0-100)."""
return self.value
@property
def as_8bit(self) -> int:
"""Return brightness as an 8-bit value (0-255)."""
return (self.value * 255 + 50) // 100
@property
def as_ui_scale(self) -> int:
"""Return brightness on a UI scale (0-5) using default scale."""
return self.to_ui_scale(None)
def to_ui_scale(self, custom_scale: dict[int, int] | None = None) -> int:
"""Convert brightness percentage to UI scale (0-5).
Args:
custom_scale: Optional custom brightness scale mapping (0-5 to percentage)
Returns:
UI scale value (0-5) that best matches the current brightness percentage
"""
if custom_scale is not None:
# Use custom scale - find the closest match
# Sort scale values to find the right bracket
scale_pairs = sorted(custom_scale.items(), key=lambda x: x[1])
# Find which bracket our value falls into
for i in range(len(scale_pairs)):
level, percent = scale_pairs[i]
if i == len(scale_pairs) - 1:
# Last level - if we're close enough, use it
return level
next_percent = scale_pairs[i + 1][1]
midpoint = (percent + next_percent) // 2
if self.value <= midpoint:
return level
return scale_pairs[-1][0] # Default to highest level
else:
# Use default scale
if self.value == 0:
return 0
elif self.value <= 3:
return 1
elif self.value <= 5:
return 2
elif self.value <= 12:
return 3
elif self.value <= 35:
return 4
else:
return 5
@classmethod
def from_ui_scale(
cls, ui_value: int, custom_scale: dict[int, int] | None = None
) -> Self:
"""Create a Brightness object from a UI scale value (0-5).
Args:
ui_value: The UI scale value (0-5)
custom_scale: Optional custom brightness scale mapping (0-5 to percentage)
Returns:
A Brightness object with the appropriate percentage value
"""
if custom_scale is not None:
# Use custom scale if provided
percent = custom_scale.get(ui_value, 20) # Default to 20%
else:
# Use default scale
lookup = {
0: 0,
1: 3,
2: 5,
3: 12,
4: 35,
5: 100,
}
percent = lookup.get(ui_value, 20) # Default to 20%
return cls(percent)
def __int__(self) -> int:
return self.value
def __repr__(self) -> str:
return f"Brightness({self.value}%)"
def __eq__(self, other: object) -> bool:
if isinstance(other, Brightness):
return self.value == other.value
if isinstance(other, int):
return self.value == other
return NotImplemented
def __lt__(self, other: object) -> bool:
if isinstance(other, Brightness):
return self.value < other.value
if isinstance(other, int):
return self.value < other
return NotImplemented
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
"""Pydantic custom schema for validation and serialization."""
from_int_schema = core_schema.chain_schema(
[
core_schema.int_schema(ge=0, le=100),
core_schema.no_info_plain_validator_function(cls),
]
)
return core_schema.json_or_python_schema(
json_schema=from_int_schema,
python_schema=core_schema.union_schema(
[
core_schema.is_instance_schema(cls),
from_int_schema,
]
),
serialization=core_schema.plain_serializer_function_ser_schema(
lambda instance: instance.value
),
)
def validate_timezone(tz: str | None) -> str | None:
"""
Validate if a timezone string is valid.
Args:
tz (str | None): The timezone string to validate.
Returns:
str | None: The timezone string if it is valid.
"""
if not tz:
return tz
try:
ZoneInfo(tz)
return tz
except Exception:
return None
def format_time(v: Any) -> str | None:
"""
Format time from int to HH:MM string.
Args:
v (Any): The value to format.
Returns:
str | None: The formatted time string or None.
"""
if isinstance(v, int):
return f"{v:02d}:00"
if isinstance(v, str):
return v
return None
class Location(BaseModel):
"""Pydantic model for a location."""
name: Annotated[
str | None,
Field(
description="Deprecated: kept for backward compatibility", deprecated=True
),
] = None
locality: str = ""
description: str = ""
place_id: str = ""
timezone: Annotated[str | None, AfterValidator(validate_timezone)] = None
lat: float
lng: float
class ProtocolType(str, Enum):
"""Protocol type enumeration."""
HTTP = "HTTP"
WS = "WS"
class DeviceInfoBase(BaseModel):
"""Pydantic model for device information that is reported by the device."""
firmware_version: str | None = None
firmware_type: str | None = None
protocol_version: int | None = None
mac_address: str | None = Field(
default=None, validation_alias=AliasChoices("mac_address", "mac")
)
class DeviceInfo(DeviceInfoBase):
"""Pydantic model for device information that is reported by the device."""
protocol_type: ProtocolType | None = None
def parse_custom_brightness_scale(scale_str: str) -> dict[int, int] | None:
"""Parse custom brightness scale string into a dictionary.
Args:
scale_str: Comma-separated values like "0,3,5,12,35,100"
Returns:
Dictionary mapping 0-5 to percentage values, or None if invalid
"""
if not scale_str or not scale_str.strip():
return None
try:
values = [int(v.strip()) for v in scale_str.split(",")]
if len(values) != 6:
return None
# Validate all values are between 0-100
if not all(0 <= v <= 100 for v in values):
return None
# Create mapping
return {i: values[i] for i in range(6)}
except (ValueError, AttributeError):
return None
class Device(BaseModel):
"""Pydantic model for a device."""
model_config = ConfigDict(validate_assignment=True)
id: DeviceID
name: str = ""
type: DeviceType = DEFAULT_DEVICE_TYPE
api_key: str = ""
img_url: str = ""
ws_url: str = ""
notes: str = ""
brightness: Brightness = Brightness(100)
custom_brightness_scale: str = "" # Format: "0,3,5,12,35,100" for levels 0-5
night_mode_enabled: bool = False
night_mode_app: str = ""
night_start: Annotated[str | None, BeforeValidator(format_time)] = (
None # Time in HH:MM format or legacy int (hour only)
)
night_end: Annotated[str | None, BeforeValidator(format_time)] = (
None # Time in HH:MM format or legacy int (hour only)
)
night_brightness: Brightness = Brightness(0)
dim_time: str | None = None # Time in HH:MM format when dimming should start
dim_brightness: Brightness | None = None
default_interval: int = Field(
15, ge=0, description="Default interval in minutes (>= 0)"
)
timezone: Annotated[str | None, AfterValidator(validate_timezone)] = None
locale: str | None = None
location: Location | None = None
apps: dict[str, App] = {}
last_app_index: int = 0
pinned_app: str | None = None # iname of the pinned app, if any
interstitial_enabled: bool = False # whether interstitial app feature is enabled
interstitial_app: str | None = None # iname of the interstitial app, if any
last_seen: datetime | None = None
info: DeviceInfo = Field(default_factory=DeviceInfo)
color_filter: ColorFilter | None = None
night_color_filter: ColorFilter | None = None
def supports_2x(self) -> bool:
"""
Check if the device supports 2x apps.
:return: True if the device supports 2x apps, False otherwise.
"""
return self.type in TWO_X_CAPABLE_DEVICE_TYPES

View File

@@ -1,28 +0,0 @@
from pydantic import BaseModel, field_validator, field_serializer
import base64
import binascii
from typing import Type, Any
class SyncPayload(BaseModel):
payload: bytes | int
@field_serializer("payload")
def serialize_payload(self, payload: bytes | int, _info: Any) -> str | int:
if isinstance(payload, bytes):
return base64.b64encode(payload).decode("ascii")
return payload
@field_validator("payload", mode="before")
@classmethod
def decode_base64(cls: Type["SyncPayload"], v: Any) -> bytes | int:
if isinstance(v, str):
try:
return base64.b64decode(v)
except binascii.Error:
# If it's a string but not base64, let Pydantic handle it as a string
# which will then fail if the target type is bytes or int
pass
if isinstance(v, (bytes, int)):
return v
raise ValueError("Payload must be bytes, int, or a base64-encoded string")

View File

@@ -1,27 +0,0 @@
"""Data models for Tronbyt Server users."""
from enum import Enum
from pydantic import BaseModel, Field
from .device import Device
class ThemePreference(str, Enum):
"""Theme preference enumeration."""
LIGHT = "light"
DARK = "dark"
SYSTEM = "system"
class User(BaseModel):
"""Pydantic model for a user."""
username: str = Field(pattern=r"^[A-Za-z0-9_-]+$")
password: str
email: str = ""
devices: dict[str, Device] = {}
api_key: str = ""
theme_preference: ThemePreference = ThemePreference.SYSTEM
system_repo_url: str = ""
app_repo_url: str = ""

View File

@@ -1,73 +0,0 @@
"""Pydantic models for WebSocket messages."""
from typing import Literal
from pydantic import BaseModel
from tronbyt_server.models.device import DeviceInfoBase
# Client-to-server messages
class QueuedMessage(BaseModel):
"""Device has queued/buffered the image."""
queued: int
class DisplayingMessage(BaseModel):
"""Device has started displaying the image."""
displaying: int
class DisplayingStatusMessage(BaseModel):
"""Device has started displaying the image (alternative format)."""
status: Literal["displaying"]
counter: int
class ClientInfo(DeviceInfoBase):
"""Pydantic model for the client_info object from a device."""
pass
class ClientInfoMessage(BaseModel):
"""Pydantic model for a client_info message."""
client_info: ClientInfo
ClientMessage = (
QueuedMessage | DisplayingMessage | DisplayingStatusMessage | ClientInfoMessage
)
# Server-to-client messages
class DwellSecsMessage(BaseModel):
"""Set the dwell time for the current image."""
dwell_secs: int
class BrightnessMessage(BaseModel):
"""Set the display brightness."""
brightness: int
class ImmediateMessage(BaseModel):
"""Instruct the device to display the most recently queued image immediately."""
immediate: bool
class StatusMessage(BaseModel):
"""Inform the device of a status update."""
status: Literal["error", "warning", "info", "debug"]
message: str
ServerMessage = DwellSecsMessage | BrightnessMessage | ImmediateMessage | StatusMessage

View File

@@ -1,327 +0,0 @@
"""Wrapper around the Pixlet C library."""
import ctypes
import json
import logging
import platform
from pathlib import Path
from threading import Lock
from typing import Any, Callable
from tronbyt_server.config import get_settings
logger = logging.getLogger(__name__)
# Corresponds to libpixlet API, incremented for breaking changes.
# libpixlet does not guarantee forwards or backwards compatibility yet.
EXPECTED_LIBPIXLET_API_VERSION = 1
pixlet_render_app: (
Callable[
[
bytes, # path
bytes, # config
int, # width
int, # height
int, # maxDuration
int, # timeout
int, # imageFormat
int, # silenceOutput
bool, # output2x
bytes | None, # filters
bytes | None, # tz
bytes | None, # locale
],
Any,
]
| None
) = None
pixlet_get_schema: Callable[[bytes, int, int, bool], Any] | None = (
None # path, width, height, output2x
)
pixlet_call_handler: (
Callable[
[
ctypes.c_char_p, # path
ctypes.c_char_p, # config
int, # width
int, # height
bool, # output2x
ctypes.c_char_p, # handlerName
ctypes.c_char_p, # parameter
],
Any,
]
| None
) = None
pixlet_init_cache: Callable[[], None] | None = None
pixlet_init_redis_cache: Callable[[bytes], None] | None = None
pixlet_free_bytes: Callable[[Any], None] | None = None
# Constants for default libpixlet paths
_LIBPIXLET_PATH_LINUX = Path("/usr/lib/libpixlet.so")
_LIBPIXLET_PATH_MACOS_ARM = Path("/opt/homebrew/lib/libpixlet.dylib")
_LIBPIXLET_PATH_MACOS_INTEL = Path("/usr/local/lib/libpixlet.dylib")
def load_pixlet_library() -> None:
libpixlet_path_str = get_settings().LIBPIXLET_PATH
if libpixlet_path_str:
libpixlet_path = Path(libpixlet_path_str)
else:
system = platform.system()
if system == "Darwin":
# Start with the Apple Silicon path
libpixlet_path = _LIBPIXLET_PATH_MACOS_ARM
if not libpixlet_path.exists():
# Fallback to the Intel path if the ARM path doesn't exist
libpixlet_path = _LIBPIXLET_PATH_MACOS_INTEL
else: # Linux and others
libpixlet_path = _LIBPIXLET_PATH_LINUX
logger.info(f"Loading {libpixlet_path}")
try:
pixlet_library = ctypes.cdll.LoadLibrary(str(libpixlet_path))
except OSError as e:
raise RuntimeError(f"Failed to load {libpixlet_path}: {e}")
try:
api_version = ctypes.c_int.in_dll(pixlet_library, "libpixletAPIVersion").value
except ValueError:
api_version = 0
logger.info(f"Libpixlet API version: {api_version}")
if api_version != EXPECTED_LIBPIXLET_API_VERSION:
raise RuntimeError(
f"FATAL: libpixlet API version mismatch. Expected {EXPECTED_LIBPIXLET_API_VERSION}, found {api_version}"
)
global pixlet_init_redis_cache
pixlet_init_redis_cache = pixlet_library.init_redis_cache
pixlet_init_redis_cache.argtypes = [ctypes.c_char_p]
global pixlet_init_cache
pixlet_init_cache = pixlet_library.init_cache
global pixlet_render_app
pixlet_render_app = pixlet_library.render_app
pixlet_render_app.argtypes = [
ctypes.c_char_p, # path
ctypes.c_char_p, # config
ctypes.c_int, # width
ctypes.c_int, # height
ctypes.c_int, # maxDuration
ctypes.c_int, # timeout
ctypes.c_int, # imageFormat
ctypes.c_int, # silenceOutput
ctypes.c_bool, # output2x
ctypes.c_char_p, # filters
ctypes.c_char_p, # tz
ctypes.c_char_p, # locale
]
# Use c_void_p for the return type to avoid ctype's automatic copying into bytes() objects.
# We need the exact pointer value so that we can free it later using pixlet_free_bytes.
class RenderAppReturn(ctypes.Structure):
_fields_ = [
("data", ctypes.c_void_p),
("length", ctypes.c_int),
("messages", ctypes.c_void_p),
("error", ctypes.c_void_p),
]
class StringReturn(ctypes.Structure):
_fields_ = [
("data", ctypes.c_void_p),
("status", ctypes.c_int),
]
class CallHandlerReturn(ctypes.Structure):
_fields_ = [
("data", ctypes.c_void_p),
("status", ctypes.c_int),
("error", ctypes.c_void_p),
]
pixlet_render_app.restype = RenderAppReturn
global pixlet_get_schema
pixlet_get_schema = pixlet_library.get_schema
pixlet_get_schema.argtypes = [
ctypes.c_char_p, # path
ctypes.c_int, # width
ctypes.c_int, # height
ctypes.c_bool, # output2x
]
pixlet_get_schema.restype = StringReturn
global pixlet_call_handler
pixlet_call_handler = pixlet_library.call_handler
pixlet_call_handler.argtypes = [
ctypes.c_char_p, # path
ctypes.c_char_p, # config
ctypes.c_int, # width
ctypes.c_int, # height
ctypes.c_bool, # output2x
ctypes.c_char_p, # handlerName
ctypes.c_char_p, # parameter
]
pixlet_call_handler.restype = CallHandlerReturn
global pixlet_free_bytes
pixlet_free_bytes = pixlet_library.free_bytes
pixlet_free_bytes.argtypes = [ctypes.c_void_p]
_pixlet_initialized = False
_pixlet_lock = Lock()
def initialize_pixlet_library() -> None:
global _pixlet_initialized
if _pixlet_initialized:
return
with _pixlet_lock:
if _pixlet_initialized:
return
load_pixlet_library()
settings = get_settings()
redis_url = settings.REDIS_URL
if redis_url and pixlet_init_redis_cache:
logger.info(f"Using Redis cache at {redis_url}")
pixlet_init_redis_cache(redis_url.encode("utf-8"))
elif pixlet_init_cache:
pixlet_init_cache()
_pixlet_initialized = True
def c_char_p_to_string(c_pointer: ctypes.c_char_p | None) -> str | None:
if not c_pointer:
return None
data = ctypes.string_at(c_pointer) # Extract the NUL-terminated C-String
result = data.decode("utf-8") # Decode the C-String to Python string
if pixlet_free_bytes:
pixlet_free_bytes(c_pointer) # Free the original C pointer
return result
def render_app(
path: Path,
config: dict[str, Any],
width: int,
height: int,
maxDuration: int,
timeout: int,
image_format: int,
supports2x: bool = False,
filters: dict[str, Any] | None = None,
tz: str | None = None,
locale: str | None = None,
) -> tuple[bytes | None, list[str]]:
initialize_pixlet_library()
if not pixlet_render_app:
logger.debug("failed to init pixlet_library")
return None, []
filters_json = json.dumps(filters).encode("utf-8") if filters else None
tz_bytes = tz.encode("utf-8") if tz else None
locale_bytes = locale.encode("utf-8") if locale else None
ret = pixlet_render_app(
str(path).encode("utf-8"),
json.dumps(config).encode("utf-8"),
width,
height,
maxDuration,
timeout,
image_format,
1,
supports2x,
filters_json,
tz_bytes,
locale_bytes,
)
error = c_char_p_to_string(ret.error)
messagesJSON = c_char_p_to_string(ret.messages)
if error:
logger.error(f"Error while rendering {path}: {error}")
if ret.length >= 0:
buf = ctypes.string_at(ret.data, ret.length)
if pixlet_free_bytes and ret.data:
pixlet_free_bytes(ret.data)
messages: list[str] = []
if messagesJSON:
try:
messages = json.loads(messagesJSON)
except Exception as e:
logger.error(f"Error: {e}")
return buf, messages
match ret.length:
case -1:
logger.error(f"Invalid config for {path}")
case -2:
logger.error(f"Render failure for {path}")
case -3:
logger.error(f"Invalid filters for {path}")
case -4:
logger.error(f"Handler failure for {path}")
case -5:
logger.error(f"Invalid path for {path}")
case -6:
logger.error(f"Star suffix error for {path}")
case -7:
logger.error(f"Unknown applet for {path}")
case -8:
logger.error(f"Schema failure for {path}")
case -9:
logger.error(f"Invalid timezone for {path}")
case -10:
logger.error(f"Invalid locale for {path}")
case _:
logger.error(f"Unknown error for {path}: {ret.length}")
return None, []
def get_schema(path: Path, width: int, height: int, supports2x: bool) -> str | None:
initialize_pixlet_library()
if not pixlet_get_schema:
return None
ret = pixlet_get_schema(str(path).encode("utf-8"), width, height, supports2x)
schema = c_char_p_to_string(ret.data)
if ret.status != 0:
return None
return schema
def call_handler(
path: Path,
config: dict[str, Any],
handler: str,
parameter: str,
width: int,
height: int,
supports2x: bool,
) -> str | None:
initialize_pixlet_library()
if not pixlet_call_handler:
return None
ret = pixlet_call_handler(
ctypes.c_char_p(str(path).encode("utf-8")),
ctypes.c_char_p(json.dumps(config).encode("utf-8")),
width,
height,
supports2x,
ctypes.c_char_p(handler.encode("utf-8")),
ctypes.c_char_p(parameter.encode("utf-8")),
)
res = c_char_p_to_string(ret.data)
error = c_char_p_to_string(ret.error)
if error:
logger.error(f"Error while calling handler {handler} for {path}: {error}")
if ret.status != 0:
return None
return res

View File

@@ -1 +0,0 @@
"""Routers for the application."""

View File

@@ -1,489 +0,0 @@
"""API router."""
import base64
import hashlib
import logging
import sqlite3
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel
from werkzeug.utils import secure_filename
from tronbyt_server import db
from tronbyt_server.dependencies import get_db, get_user_and_device_from_api_key
from tronbyt_server.models import App, Device, DeviceID, User, Brightness
from tronbyt_server.routers.manager import parse_time_input
from tronbyt_server.utils import push_image, render_app
router = APIRouter(prefix="/v0", tags=["api"])
logger = logging.getLogger(__name__)
# Constants for dots SVG generation
DEFAULT_DOTS_WIDTH = 64
DEFAULT_DOTS_HEIGHT = 32
DEFAULT_DOTS_RADIUS = 0.3
MAX_DOTS_DIMENSION = 256
class DeviceUpdate(BaseModel):
brightness: int | None = None
intervalSec: int | None = None
nightModeEnabled: bool | None = None
nightModeApp: str | None = None
nightModeBrightness: int | None = None
nightModeStartTime: str | None = None
nightModeEndTime: str | None = None
dimModeStartTime: str | None = None
dimModeBrightness: int | None = None
pinnedApp: str | None = None
# Legacy Tidbyt field
autoDim: bool | None = None
class PushData(BaseModel):
installationID: str | None = None
installationId: str | None = None
image: str
class PatchDeviceData(BaseModel):
enabled: bool | None = None
pinned: bool | None = None
renderIntervalMin: int | None = None
displayTimeSec: int | None = None
# Legacy fields
set_enabled: bool | None = None
set_pinned: bool | None = None
class PushAppData(BaseModel):
config: dict[str, Any]
app_id: str
installationID: str | None = None
installationId: str | None = None
def get_device_payload(device: Device) -> dict[str, Any]:
return {
"id": device.id,
"type": device.type,
"displayName": device.name,
"notes": device.notes,
"intervalSec": device.default_interval,
"brightness": db.get_device_brightness_percent(device),
"nightMode": {
"enabled": device.night_mode_enabled,
"app": device.night_mode_app,
"startTime": device.night_start,
"endTime": device.night_end,
"brightness": device.night_brightness.as_percent,
},
"dimMode": {
"startTime": device.dim_time,
"brightness": device.dim_brightness.as_percent
if device.dim_brightness
else None,
},
"pinnedApp": device.pinned_app,
"interstitial": {
"enabled": device.interstitial_enabled,
"app": device.interstitial_app,
},
"lastSeen": device.last_seen.isoformat() if device.last_seen else None,
"info": {
"firmwareVersion": device.info.firmware_version,
"firmwareType": device.info.firmware_type,
"protocolVersion": device.info.protocol_version,
"macAddress": device.info.mac_address,
"protocolType": device.info.protocol_type.value
if device.info.protocol_type
else None,
},
# Legacy Tidbyt field
"autoDim": device.night_mode_enabled,
}
def get_app_payload(device: Device, app: App) -> dict[str, Any]:
return {
"id": app.iname,
"appID": app.name,
"enabled": app.enabled,
"pinned": device.pinned_app == app.iname,
"pushed": app.pushed,
"renderIntervalMin": app.uinterval,
"displayTimeSec": app.display_time,
"lastRenderAt": app.last_render,
"isInactive": app.empty_last_render,
}
@router.get("/dots")
def generate_dots_svg(
request: Request,
w: int = DEFAULT_DOTS_WIDTH,
h: int = DEFAULT_DOTS_HEIGHT,
r: float = DEFAULT_DOTS_RADIUS,
) -> Response:
"""Generate an SVG mask pattern with dots."""
# ETag generation
etag_content = f"{w}-{h}-{r}"
etag = f'"{hashlib.md5(etag_content.encode()).hexdigest()}"'
# Check If-None-Match header
if request.headers.get("if-none-match") == etag:
return Response(status_code=status.HTTP_304_NOT_MODIFIED)
# Validate width
if w <= 0 or w > MAX_DOTS_DIMENSION:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter 'w' must be between 1 and {MAX_DOTS_DIMENSION}",
)
# Validate height
if h <= 0 or h > MAX_DOTS_DIMENSION:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Parameter 'h' must be between 1 and {MAX_DOTS_DIMENSION}",
)
# Validate radius
if r <= 0 or r > 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Parameter 'r' must be between 0 and 1",
)
# Generate SVG with dots
data = [
'<?xml version="1.0" encoding="UTF-8"?>',
f'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" fill="#fff">',
]
# Pre-compute radius string to avoid repeated formatting
radius_str = f"{r:g}"
for y in range(h):
y_str = f"{y + 0.5:g}"
for x in range(w):
x_str = f"{x + 0.5:g}"
data.append(f'<circle cx="{x_str}" cy="{y_str}" r="{radius_str}"/>')
data.append("</svg>\n")
headers = {
"Cache-Control": "public, max-age=31536000",
"ETag": etag,
}
return Response(content="".join(data), media_type="image/svg+xml", headers=headers)
@router.get("/devices", response_model=dict[str, list[dict[str, Any]]])
def list_devices(
auth: tuple[User | None, Device | None] = Depends(get_user_and_device_from_api_key),
) -> dict[str, list[dict[str, Any]]]:
user, _ = auth
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
)
devices = user.devices
metadata = [get_device_payload(device) for device in devices.values()]
return {"devices": metadata}
@router.get("/devices/{device_id}")
def get_device(
device_id: DeviceID,
auth: tuple[User | None, Device | None] = Depends(get_user_and_device_from_api_key),
) -> dict[str, Any]:
_, device = auth
if not device or device.id != device_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Device not found"
)
return get_device_payload(device)
@router.patch("/devices/{device_id}")
def update_device(
device_id: DeviceID,
data: DeviceUpdate,
db_conn: sqlite3.Connection = Depends(get_db),
auth: tuple[User | None, Device | None] = Depends(get_user_and_device_from_api_key),
) -> dict[str, Any]:
user, device = auth
if not user or not device or device.id != device_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Device not found"
)
if data.brightness is not None:
if not 0 <= data.brightness <= 100:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Brightness must be between 0 and 100",
)
device.brightness = Brightness(data.brightness)
if data.autoDim is not None:
device.night_mode_enabled = data.autoDim
if data.intervalSec is not None:
if data.intervalSec < 1:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Interval must be greater than 0",
)
device.default_interval = data.intervalSec
if data.nightModeEnabled is not None:
device.night_mode_enabled = data.nightModeEnabled
if data.nightModeApp is not None:
if data.nightModeApp and data.nightModeApp not in device.apps:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Night mode app not found",
)
device.night_mode_app = data.nightModeApp
if data.nightModeBrightness is not None:
if not 0 <= data.nightModeBrightness <= 100:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Night mode brightness must be between 0 and 100",
)
device.night_brightness = Brightness(data.nightModeBrightness)
if data.nightModeStartTime is not None:
device.night_start = parse_time_input(data.nightModeStartTime)
if data.nightModeEndTime is not None:
device.night_end = parse_time_input(data.nightModeEndTime)
if data.dimModeStartTime is not None:
if data.dimModeStartTime == "":
device.dim_time = None
else:
device.dim_time = parse_time_input(data.dimModeStartTime)
if data.dimModeBrightness is not None:
if not 0 <= data.dimModeBrightness <= 100:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Dim brightness must be between 0 and 100",
)
device.dim_brightness = Brightness(data.dimModeBrightness)
if data.pinnedApp is not None:
if data.pinnedApp and data.pinnedApp not in device.apps:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Pinned app not found",
)
device.pinned_app = data.pinnedApp
user.devices[device_id] = device
db.save_user(db_conn, user)
return get_device_payload(user.devices[device_id])
@router.post("/devices/{device_id}/push")
async def handle_push(
device_id: DeviceID,
data: PushData,
auth: tuple[User | None, Device | None] = Depends(get_user_and_device_from_api_key),
db_conn: sqlite3.Connection = Depends(get_db),
) -> Response:
_, device = auth
if not device or device.id != device_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Device not found or invalid API key",
)
installation_id = data.installationID or data.installationId
try:
image_bytes = base64.b64decode(data.image)
except Exception:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid image data"
)
await push_image(device_id, installation_id, image_bytes, db_conn)
return Response("WebP received.", status_code=status.HTTP_200_OK)
@router.get("/devices/{device_id}/installations")
def list_installations(
device_id: DeviceID,
auth: tuple[User | None, Device | None] = Depends(get_user_and_device_from_api_key),
) -> Response:
_, device = auth
if not device or device.id != device_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
apps = device.apps
installations = [get_app_payload(device, app) for app in apps.values()]
return JSONResponse(content={"installations": installations})
@router.get("/devices/{device_id}/installations/{installation_id}")
def get_installation(
device_id: DeviceID,
installation_id: str,
auth: tuple[User | None, Device | None] = Depends(get_user_and_device_from_api_key),
) -> dict[str, Any]:
_, device = auth
if not device or device.id != device_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Device not found"
)
installation_id = secure_filename(installation_id)
apps = device.apps
app = apps.get(installation_id)
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Installation not found"
)
return get_app_payload(device, app)
@router.patch("/devices/{device_id}/installations/{installation_id}")
def handle_patch_device_app(
device_id: DeviceID,
installation_id: str,
data: PatchDeviceData,
db_conn: sqlite3.Connection = Depends(get_db),
auth: tuple[User | None, Device | None] = Depends(get_user_and_device_from_api_key),
) -> Response:
user, device = auth
if not user or not device or device.id != device_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Device not found or invalid API key",
)
installation_id = secure_filename(installation_id)
apps = device.apps
app = apps.get(installation_id)
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="App not found"
)
if data.set_enabled is not None and data.enabled is None:
data.enabled = data.set_enabled
if data.set_pinned is not None and data.pinned is None:
data.pinned = data.set_pinned
# Handle the enabled command
if data.enabled is not None:
app.enabled = data.enabled
if app.enabled:
app.last_render = 0
else:
webp_path = db.get_device_webp_dir(device.id)
file_path = webp_path / f"{app.name}-{installation_id}.webp"
if file_path.is_file():
file_path.unlink()
if data.pinned is not None:
if data.pinned:
device.pinned_app = installation_id
elif device.pinned_app == installation_id:
device.pinned_app = None
if data.renderIntervalMin is not None:
if data.renderIntervalMin < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Render interval must be greater than or equal to 0",
)
app.uinterval = data.renderIntervalMin
if data.displayTimeSec is not None:
if data.displayTimeSec < 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Display time must be greater than or equal to 0",
)
app.display_time = data.displayTimeSec
device.apps[app.iname] = app
user.devices[device_id] = device
db.save_user(db_conn, user)
return JSONResponse(content=get_app_payload(device, app))
@router.delete("/devices/{device_id}/installations/{installation_id}")
def handle_delete(
device_id: DeviceID,
installation_id: str,
auth: tuple[User | None, Device | None] = Depends(get_user_and_device_from_api_key),
) -> Response:
_, device = auth
if not device or device.id != device_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
pushed_webp_path = db.get_device_webp_dir(device.id) / "pushed"
installation_id = secure_filename(installation_id)
file_path = pushed_webp_path / f"{installation_id}.webp"
if not file_path.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
file_path.unlink()
return Response("Webp deleted.", status_code=status.HTTP_200_OK)
@router.post("/devices/{device_id}/push_app")
async def handle_app_push(
device_id: DeviceID,
data: PushAppData,
db_conn: sqlite3.Connection = Depends(get_db),
auth: tuple[User | None, Device | None] = Depends(get_user_and_device_from_api_key),
) -> Response:
user, device = auth
if not user or not device or device.id != device_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Device not found or invalid API key",
)
app_details = db.get_app_details_by_id(user.username, data.app_id)
if not app_details:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Missing app path"
)
app_path = Path(app_details.path)
if not app_path.exists():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="App not found"
)
installation_id = data.installationID or data.installationId or ""
app = db.get_pushed_app(user, device_id, installation_id)
if not app:
logger.error(
"Pushed app data for device '%s' installation '%s' not found",
device_id,
installation_id,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid app data"
)
image_bytes = render_app(db_conn, app_path, data.config, None, device, app, user)
if image_bytes is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Rendering failed",
)
if len(image_bytes) == 0:
return Response("Empty image, not pushing", status_code=status.HTTP_200_OK)
if installation_id:
apps = user.devices[device_id].apps
apps[installation_id] = app
db.save_user(db_conn, user)
await push_image(device_id, installation_id, image_bytes, db_conn)
return Response("App pushed.", status_code=status.HTTP_200_OK)

View File

@@ -1,403 +0,0 @@
"""Authentication router."""
import secrets
import sqlite3
import string
import time
from datetime import timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import JSONResponse, RedirectResponse, Response
from fastapi_babel import _
from pydantic import BaseModel
from werkzeug.security import generate_password_hash
import tronbyt_server.db as db
from tronbyt_server import system_apps, version
from tronbyt_server.config import Settings, get_settings
from tronbyt_server.dependencies import (
get_db,
is_auto_login_active,
is_trusted_network,
manager,
)
from tronbyt_server.flash import flash
from tronbyt_server.models import ThemePreference, User
from tronbyt_server.templates import templates
router = APIRouter(prefix="/auth", tags=["auth"])
def _generate_api_key() -> str:
"""Generate a random API key."""
return "".join(
secrets.choice(string.ascii_letters + string.digits) for _ in range(32)
)
def _render_edit_template(request: Request, user: User) -> Response:
"""Render the edit user page template with the correct context."""
firmware_version = None
system_repo_info = None
server_version_info = version.get_version_info()
update_available, latest_release_url = version.check_for_updates(
server_version_info
)
if user and user.username == "admin":
firmware_version = db.get_firmware_version()
system_repo_info = system_apps.get_system_repo_info(db.get_data_dir())
context = {
"user": user,
"firmware_version": firmware_version,
"system_repo_info": system_repo_info,
"server_version_info": server_version_info,
"update_available": update_available,
"latest_release_url": latest_release_url,
}
return templates.TemplateResponse(
request,
"auth/edit.html",
context,
)
@router.get("/register_owner")
def get_register_owner(
request: Request, db_conn: sqlite3.Connection = Depends(get_db)
) -> Response:
"""Render the owner registration page."""
if db.has_users(db_conn):
return RedirectResponse(
url=request.url_for("login"), status_code=status.HTTP_302_FOUND
)
return templates.TemplateResponse(request, "auth/register_owner.html")
@router.post("/register_owner")
def post_register_owner(
request: Request,
password: str = Form(...),
db_conn: sqlite3.Connection = Depends(get_db),
) -> Response:
"""Handle owner registration."""
if db.has_users(db_conn):
return RedirectResponse(
url=request.url_for("login"), status_code=status.HTTP_302_FOUND
)
if not password:
flash(request, _("Password is required."))
else:
username = "admin"
api_key = _generate_api_key()
user = User(
username=username,
password=generate_password_hash(password),
api_key=api_key,
)
if db.save_user(db_conn, user, new_user=True):
db.create_user_dir(username)
# Don't show "Please log in" message if auto-login is active
if not is_auto_login_active(db_conn):
flash(request, _("admin user created. Please log in."))
return RedirectResponse(
url=request.url_for("login"), status_code=status.HTTP_302_FOUND
)
else:
flash(request, _("Could not create admin user."))
return templates.TemplateResponse(request, "auth/register_owner.html")
@router.get("/register", name="register")
def get_register(
request: Request,
user: User | None = Depends(manager.optional),
db_conn: sqlite3.Connection = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> Response:
"""Render the user registration page."""
if not db.has_users(db_conn):
return RedirectResponse(
url=request.url_for("get_register_owner"), status_code=status.HTTP_302_FOUND
)
if settings.ENABLE_USER_REGISTRATION != "1":
if not user or user.username != "admin":
flash(request, _("User registration is not enabled."))
return RedirectResponse(
url=request.url_for("login"), status_code=status.HTTP_302_FOUND
)
return templates.TemplateResponse(request, "auth/register.html", {"user": user})
class RegisterFormData(BaseModel):
"""Represents the form data for user registration."""
username: str
password: str
email: str = ""
@router.post("/register")
def post_register(
request: Request,
form_data: Annotated[RegisterFormData, Form()],
user: User | None = Depends(manager.optional),
db_conn: sqlite3.Connection = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> Response:
"""Handle user registration."""
if not db.has_users(db_conn):
return RedirectResponse(
url=request.url_for("get_register_owner"), status_code=status.HTTP_302_FOUND
)
if settings.ENABLE_USER_REGISTRATION != "1":
if not user or user.username != "admin":
flash(request, _("User registration is not enabled."))
return RedirectResponse(
url=request.url_for("login"), status_code=status.HTTP_302_FOUND
)
max_users = settings.MAX_USERS
if max_users > 0 and len(db.get_all_users(db_conn)) >= max_users:
flash(
request,
_("Maximum number of users reached. Registration is disabled."),
)
return RedirectResponse(
url=request.url_for("login"), status_code=status.HTTP_302_FOUND
)
error = None
status_code = status.HTTP_200_OK
if not form_data.username:
error = _("Username is required.")
status_code = status.HTTP_400_BAD_REQUEST
elif not form_data.password:
error = _("Password is required.")
status_code = status.HTTP_400_BAD_REQUEST
elif db.get_user(db_conn, form_data.username):
error = _("User is already registered.")
status_code = status.HTTP_409_CONFLICT
if error is None:
api_key = _generate_api_key()
new_user = User(
username=form_data.username,
password=generate_password_hash(form_data.password),
email=form_data.email,
api_key=api_key,
)
if db.save_user(db_conn, new_user, new_user=True):
db.create_user_dir(form_data.username)
if user and user.username == "admin":
flash(
request,
_("User {username} registered successfully.").format(
username=form_data.username
),
)
return RedirectResponse(
url=request.url_for("get_register"),
status_code=status.HTTP_302_FOUND,
)
else:
flash(
request,
_("Registered as {username}.").format(username=form_data.username),
)
return RedirectResponse(
url=request.url_for("login"), status_code=status.HTTP_302_FOUND
)
else:
error = _("Couldn't Save User")
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
if error:
flash(request, error)
return templates.TemplateResponse(
request, "auth/register.html", {"user": user}, status_code=status_code
)
@router.get("/login")
def login(
request: Request,
db_conn: sqlite3.Connection = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> Response:
"""Render the login page."""
if not db.has_users(db_conn):
return RedirectResponse(
url=request.url_for("get_register_owner"), status_code=status.HTTP_302_FOUND
)
# If auto-login mode is active, redirect to home page
if is_auto_login_active(db_conn):
client_host = request.client.host if request.client else None
if is_trusted_network(client_host):
# Auto-login is available - redirect to home instead of showing login form
return RedirectResponse(
url=request.url_for("index"), status_code=status.HTTP_302_FOUND
)
return templates.TemplateResponse(request, "auth/login.html", {"config": settings})
class LoginFormData(BaseModel):
"""Represents the form data for user login."""
username: str
password: str
remember: str | None = None
@router.post("/login")
def post_login(
request: Request,
form_data: Annotated[LoginFormData, Form()],
db_conn: sqlite3.Connection = Depends(get_db),
settings: Settings = Depends(get_settings),
) -> Response:
"""Handle user login."""
if not db.has_users(db_conn):
return RedirectResponse(
url=request.url_for("get_register_owner"), status_code=status.HTTP_302_FOUND
)
user_data = db.auth_user(db_conn, form_data.username, form_data.password)
if not isinstance(user_data, User):
flash(request, _("Incorrect username/password."))
if not settings.PRODUCTION == "0":
time.sleep(2)
return templates.TemplateResponse(
request,
"auth/login.html",
{"config": settings},
status_code=status.HTTP_401_UNAUTHORIZED,
)
user = user_data
response = RedirectResponse(
url=request.url_for("index"), status_code=status.HTTP_302_FOUND
)
# Set token expiration
token_expires = timedelta(days=30) if form_data.remember else timedelta(minutes=60)
access_token = manager.create_access_token(
data={"sub": user.username}, expires=token_expires
)
# Set cookie expiration on the browser
cookie_max_age = (
30 * 24 * 60 * 60 if form_data.remember else None
) # 30 days or session
response.set_cookie(
key=manager.cookie_name,
value=access_token,
max_age=cookie_max_age,
secure=request.url.scheme == "https", # Set secure flag in production
httponly=True, # Standard security practice
samesite="lax", # Can be "strict" or "lax"
)
return response
@router.get("/edit", name="edit")
def get_edit(
request: Request,
user: User = Depends(manager),
) -> Response:
"""Render the edit user page."""
return _render_edit_template(request, user)
@router.post("/edit")
def post_edit(
request: Request,
old_password: str = Form(...),
password: str = Form(...),
user: User = Depends(manager),
db_conn: sqlite3.Connection = Depends(get_db),
) -> Response:
"""Handle user edit."""
authed_user_data = db.auth_user(db_conn, user.username, old_password)
if not isinstance(authed_user_data, User):
flash(request, _("Bad old password."))
return _render_edit_template(request, user)
else:
authed_user = authed_user_data
authed_user.password = generate_password_hash(password)
db.save_user(db_conn, authed_user)
flash(request, _("Success"))
return RedirectResponse(
url=request.url_for("index"), status_code=status.HTTP_302_FOUND
)
@router.get("/logout")
def logout(request: Request) -> Response:
"""Log the user out."""
flash(request, _("Logged Out"))
response = RedirectResponse(
url=request.url_for("login"), status_code=status.HTTP_302_FOUND
)
response.delete_cookie(manager.cookie_name)
return response
class ThemePreferencePayload(BaseModel):
"""Pydantic model for theme preference payload."""
theme: str
@router.post("/set_theme_preference")
def set_theme_preference(
preference: ThemePreferencePayload,
user: Annotated[User, Depends(manager)],
db_conn: sqlite3.Connection = Depends(get_db),
) -> Response:
"""Set the theme preference for a user."""
try:
theme = ThemePreference(preference.theme)
except ValueError:
return JSONResponse(
content={"status": "error", "message": "Invalid theme value"},
status_code=status.HTTP_400_BAD_REQUEST,
)
user.theme_preference = theme
if db.save_user(db_conn, user):
return JSONResponse(
content={"status": "success", "message": "Theme preference updated"}
)
else:
return JSONResponse(
content={
"status": "error",
"message": "Failed to save theme preference",
},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@router.post("/generate_api_key")
def generate_api_key(
request: Request,
user: Annotated[User, Depends(manager)],
db_conn: sqlite3.Connection = Depends(get_db),
) -> Response:
"""Generate a new API key for the user."""
user.api_key = _generate_api_key()
if db.save_user(db_conn, user):
flash(request, _("New API key generated successfully."))
else:
flash(request, _("Failed to generate new API key."))
return RedirectResponse(
url=request.url_for("get_edit"), status_code=status.HTTP_302_FOUND
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,588 +0,0 @@
"""Websockets router."""
import asyncio
import logging
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from datetime import datetime, timezone
from typing import cast
from fastapi import APIRouter, Depends, WebSocket, status, Response
from fastapi.responses import FileResponse
from pydantic import TypeAdapter, ValidationError
from starlette.websockets import WebSocketDisconnect
import re
from tronbyt_server import db
from tronbyt_server.dependencies import get_db
from tronbyt_server.models import (
BrightnessMessage,
ClientInfoMessage,
ClientMessage,
Device,
DisplayingMessage,
DisplayingStatusMessage,
DwellSecsMessage,
ImmediateMessage,
ProtocolType,
QueuedMessage,
ServerMessage,
StatusMessage,
User,
)
from tronbyt_server.routers.manager import next_app_logic
from tronbyt_server.models.sync import SyncPayload
from tronbyt_server.sync import get_sync_manager, Waiter
router = APIRouter(tags=["websockets"])
logger = logging.getLogger(__name__)
server_message_adapter: TypeAdapter[ServerMessage] = TypeAdapter(ServerMessage)
async def _send_message(websocket: WebSocket, message: ServerMessage) -> None:
"""Send a message to the websocket."""
await websocket.send_text(server_message_adapter.dump_json(message).decode("utf-8"))
class DeviceAcknowledgment:
"""Manages device acknowledgment state for queued/displaying messages."""
def __init__(self) -> None:
"""Initializes the DeviceAcknowledgment."""
self.queued_event = asyncio.Event()
self.displaying_event = asyncio.Event()
self.queued_counter: int | None = None
self.displaying_counter: int | None = None
self.brightness_to_send: int | None = None # If set, send this brightness value
def reset(self) -> None:
"""Reset events for next image."""
self.queued_event.clear()
self.displaying_event.clear()
self.queued_counter = None
self.displaying_counter = None
# Don't reset brightness_to_send - it should persist until sent
def mark_queued(self, counter: int) -> None:
"""Mark image as queued by device."""
self.queued_counter = counter
self.queued_event.set()
def mark_displaying(self, counter: int) -> None:
"""Mark image as displaying by device."""
self.displaying_counter = counter
self.displaying_event.set()
@dataclass
class Connection:
"""Represents an active WebSocket connection and its associated tasks."""
websocket: WebSocket
ack: "DeviceAcknowledgment"
sender_task: asyncio.Task[None]
receiver_task: asyncio.Task[None]
class ConnectionManager:
"""Manages active WebSocket connections."""
def __init__(self) -> None:
self._active_connections: dict[str, "Connection"] = {}
self._lock = asyncio.Lock()
async def register(
self,
device_id: str,
websocket: WebSocket,
ack: "DeviceAcknowledgment",
sender_task: asyncio.Task[None],
receiver_task: asyncio.Task[None],
) -> None:
"""Register a new connection, cleaning up any existing one."""
new_connection = Connection(
websocket=websocket,
ack=ack,
sender_task=sender_task,
receiver_task=receiver_task,
)
old_connection: "Connection | None"
async with self._lock:
old_connection = self._active_connections.get(device_id)
self._active_connections[device_id] = new_connection
logger.info(f"[{device_id}] WebSocket connection registered")
if old_connection:
logger.warning(f"[{device_id}] Existing connection found, cleaning up.")
old_connection.sender_task.cancel()
old_connection.receiver_task.cancel()
# Wait for the old tasks to finish their cleanup.
await asyncio.gather(
old_connection.sender_task,
old_connection.receiver_task,
return_exceptions=True,
)
async def unregister(self, device_id: str, websocket: WebSocket) -> None:
"""Unregister a connection if it's the current one."""
# This check is atomic and safe.
async with self._lock:
if (
connection := self._active_connections.get(device_id)
) and connection.websocket is websocket:
self._active_connections.pop(device_id)
logger.info(f"[{device_id}] WebSocket connection unregistered")
async def get_ack(self, device_id: str) -> "DeviceAcknowledgment | None":
"""Get the ack object for a given device_id atomically."""
async with self._lock:
connection = self._active_connections.get(device_id)
if connection:
return connection.ack
return None
async def get_websocket(self, device_id: str) -> WebSocket | None:
"""Get the websocket for a given device_id atomically."""
async with self._lock:
connection = self._active_connections.get(device_id)
if connection:
return connection.websocket
return None
# Global instance of the connection manager
connection_manager = ConnectionManager()
async def send_brightness_update(device_id: str, brightness: int) -> None:
"""Send a brightness update to an active websocket connection."""
get_sync_manager().notify(device_id, SyncPayload(payload=brightness))
async def _send_response(
websocket: WebSocket, response: Response, last_brightness: int
) -> tuple[int, int]:
"""Send a response to the websocket.
Returns: (dwell_time, last_brightness)
"""
dwell_time = 5
if response.status_code == 200:
# Check if this should be displayed immediately (interrupting current display)
immediate = response.headers.get("Tronbyt-Immediate")
# Get the dwell time from the response header
dwell_secs = response.headers.get("Tronbyt-Dwell-Secs")
if dwell_secs:
dwell_time = int(dwell_secs)
logger.debug(f"Sending dwell_secs to device: {dwell_time}s")
await _send_message(websocket, DwellSecsMessage(dwell_secs=dwell_time))
# Update confirmation timeout now that we have the actual dwell time
# confirmation_timeout = dwell_time
# Send brightness as a text message, if it has changed
# This must be done before sending the image so that the new value is applied to the next image
brightness_str = response.headers.get("Tronbyt-Brightness")
if brightness_str:
brightness = int(brightness_str)
if brightness != last_brightness:
await _send_message(websocket, BrightnessMessage(brightness=brightness))
last_brightness = brightness
# Send the image as a binary message FIRST
# This allows the device to queue/buffer the image before being told to interrupt
if isinstance(response, FileResponse):
content = await asyncio.get_running_loop().run_in_executor(
None, Path(response.path).read_bytes
)
await websocket.send_bytes(content)
else:
await websocket.send_bytes(cast(bytes, response.body))
# Send immediate flag AFTER image bytes so device can queue the image first
# Then immediately interrupt and display it
if immediate:
logger.debug("Sending immediate display flag to device AFTER image bytes")
await _send_message(websocket, ImmediateMessage(immediate=True))
dwell_time = int(response.headers.get("Tronbyt-Dwell-Secs", 5))
else:
await _send_message(
websocket,
StatusMessage(
status="error",
message=f"Error fetching image: {response.status_code}",
),
)
return dwell_time, last_brightness
async def _wait_for_acknowledgment(
ack: DeviceAcknowledgment,
dwell_time: int,
db_conn: sqlite3.Connection,
loop: asyncio.AbstractEventLoop,
waiter: Waiter,
websocket: WebSocket,
last_brightness: int,
user: User,
device: Device,
) -> tuple[Response, int]:
"""Wait for device to acknowledge displaying the image, with timeout and ephemeral push detection.
Returns tuple of (next Response to send, updated last_brightness).
"""
poll_interval = 1 # Check every second
time_waited = 0
# Determine timeout based on firmware type
if device.info.protocol_version is None:
# Old firmware doesn't send messages, just wait for dwell_time
extended_timeout = dwell_time
logger.debug(
f"[{device.id}] Using old firmware timeout of {extended_timeout}s (dwell_time)"
)
else:
# New firmware - give device full dwell time + buffer
# Use 2x dwell time to give plenty of room for the device to display current image
extended_timeout = max(25, int(dwell_time * 2))
while time_waited < extended_timeout:
# Create a task to wait on the sync manager waiter (for cross-thread/worker notifications)
async def _wait_on_waiter() -> SyncPayload | None:
return await loop.run_in_executor(None, waiter.wait, poll_interval)
waiter_task = asyncio.create_task(_wait_on_waiter())
# Create a task to wait on the displaying event (for device acknowledgments)
display_task = asyncio.create_task(ack.displaying_event.wait())
# Wait for either the waiter notification, display ack, or timeout
done, pending = await asyncio.wait(
{waiter_task, display_task},
timeout=poll_interval,
return_when=asyncio.FIRST_COMPLETED,
)
# Cancel any pending tasks
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# Check what triggered the wakeup
if display_task in done:
# Got displaying acknowledgment, render next image
logger.debug(f"[{device.id}] Device acknowledged display")
response = await loop.run_in_executor(
None, next_app_logic, db_conn, user, device
)
return (response, last_brightness)
sync_payload: SyncPayload | None = None
if waiter_task in done:
try:
sync_payload = waiter_task.result()
except Exception as e:
logger.warning(
f"[{device.id}] Unexpected error getting waiter result: {e}"
)
# If woken by a push notification, process the payload
if sync_payload:
match sync_payload.payload:
case bytes() as payload_bytes:
logger.debug(
f"[{device.id}] Image payload received, sending immediately"
)
# Create a response directly from the image bytes
response = Response(content=payload_bytes, media_type="image/webp")
# Set immediate flag so device displays it right away
response.headers["Tronbyt-Immediate"] = "1"
# Set a default dwell time, as it's an immediate push
response.headers["Tronbyt-Dwell-Secs"] = "5"
return (response, last_brightness)
case int() as payload_int:
logger.debug(
f"[{device.id}] Brightness payload received, sending immediately"
)
try:
await _send_message(
websocket,
BrightnessMessage(brightness=payload_int),
)
last_brightness = payload_int
except Exception as e:
logger.error(f"[{device.id}] Failed to send brightness: {e}")
# Update time waited
time_waited += poll_interval
# Timeout reached without acknowledgment
if not ack.displaying_event.is_set() and device.info.protocol_version is not None:
logger.debug(
f"[{device.id}] No display confirmation received after {extended_timeout}s"
)
# Render next image after timeout
response = await loop.run_in_executor(None, next_app_logic, db_conn, user, device)
return (response, last_brightness)
async def sender(
websocket: WebSocket,
device_id: str,
db_conn: sqlite3.Connection,
ack: DeviceAcknowledgment,
) -> None:
"""The sender task for the websocket."""
user = db.get_user_by_device_id(db_conn, device_id)
if not user:
logger.error(f"[{device_id}] User not found, sender task cannot start.")
return
device = user.devices.get(device_id)
if not device:
logger.error(f"[{device_id}] Device not found, sender task cannot start.")
return
waiter = get_sync_manager().get_waiter(device_id)
loop = asyncio.get_running_loop()
last_brightness = -1
dwell_time = 5
try:
# Render the first image before entering the loop
response = await loop.run_in_executor(
None, next_app_logic, db_conn, user, device
)
# Main loop
while True:
# Reset acknowledgment events for next image
ack.reset()
# Send the previously rendered image
dwell_time, last_brightness = await _send_response(
websocket, response, last_brightness
)
# Refresh user and device from DB in case of updates
user = db.get_user_by_device_id(db_conn, device_id)
if not user:
logger.error(f"[{device_id}] user gone, stopping websocket sender.")
return
device = user.devices.get(device_id)
if not device:
logger.error(f"[{device_id}] device gone, stopping websocket sender.")
return
# Wait for device acknowledgment with timeout and ephemeral push detection
# This will check for ephemeral pushes and render the next image when ready
response, last_brightness = await _wait_for_acknowledgment(
ack,
dwell_time,
db_conn,
loop,
waiter,
websocket,
last_brightness,
user,
device,
)
except asyncio.CancelledError:
pass # Expected on disconnect
except Exception as e:
logger.error(f"WebSocket sender error for device {device_id}: {e}")
finally:
waiter.close()
async def receiver(
websocket: WebSocket, device_id: str, db_conn: sqlite3.Connection
) -> None:
"""The receiver task for the websocket."""
adapter: TypeAdapter[ClientMessage] = TypeAdapter(ClientMessage)
try:
while True:
message = await websocket.receive_text()
try:
parsed_message = adapter.validate_json(message)
user = db.get_user_by_device_id(db_conn, device_id)
if not user:
logger.warning(
f"[{device_id}] User not found for device, cannot process message."
)
continue
try:
with db.db_transaction(db_conn) as cursor:
db.update_device_field(
cursor,
user.username,
device_id,
"last_seen",
datetime.now(timezone.utc).isoformat(),
)
# Fetch the ACK object for the CURRENTLY active connection
ack = await connection_manager.get_ack(device_id)
if not ack:
logger.warning(
f"[{device_id}] Received message but no active connection found, ignoring."
)
# Still save the last_seen update
return # Exit the 'with' block, committing changes
match parsed_message:
case QueuedMessage(queued=queued_seq):
logger.debug(
f"[{device_id}] Image queued (seq: {queued_seq})"
)
ack.mark_queued(queued_seq)
# If we get a queued message, it's a new firmware device.
# Update protocol version if not set.
device = user.devices.get(device_id)
if device and device.info.protocol_version is None:
logger.info(
f"[{device_id}] First 'queued' message, setting protocol_version to 1"
)
db.update_device_field(
cursor,
user.username,
device_id,
"info.protocol_version",
1,
)
case DisplayingMessage(displaying=displaying_seq):
logger.debug(
f"[{device_id}] Image displaying (seq: {displaying_seq})"
)
ack.mark_displaying(displaying_seq)
case DisplayingStatusMessage(counter=counter_seq):
logger.debug(
f"[{device_id}] Image displaying (seq: {counter_seq})"
)
ack.mark_displaying(counter_seq)
case ClientInfoMessage(client_info=client_info):
logger.debug(
f"[{device_id}] Received ClientInfoMessage: {client_info.model_dump_json()}"
)
info_updates = {
"firmware_version": client_info.firmware_version,
"firmware_type": client_info.firmware_type,
"protocol_version": client_info.protocol_version,
"mac_address": client_info.mac_address,
}
for field, value in info_updates.items():
if value is not None:
db.update_device_field(
cursor,
user.username,
device_id,
f"info.{field}",
value,
)
logger.info(
f"[{device_id}] Updated device info via websocket"
)
case _:
# This should not happen if the models cover all cases
logger.warning(
f"[{device_id}] Unhandled message format: {message}"
)
except sqlite3.Error as e:
logger.error(f"Database error in websocket receiver: {e}")
except (ValueError, ValidationError) as e:
logger.warning(f"[{device_id}] Failed to parse device message: {e}")
except WebSocketDisconnect:
logger.info(f"WebSocket disconnected for device {device_id}")
@router.websocket("/{device_id}/ws")
async def websocket_endpoint(
websocket: WebSocket,
device_id: str,
db_conn: sqlite3.Connection = Depends(get_db),
) -> None:
"""WebSocket endpoint for devices."""
if not re.match(r"^[a-fA-F0-9]{8}$", device_id):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
user = db.get_user_by_device_id(db_conn, device_id)
if not user:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
device = user.devices.get(device_id)
if not device:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return
await websocket.accept()
# Immediately update protocol type to WS on successful connection
try:
with db.db_transaction(db_conn) as cursor:
db.update_device_field(
cursor,
user.username,
device_id,
"info.protocol_type",
ProtocolType.WS.value,
)
logger.info(f"[{device_id}] Updated protocol_type to WS on connect")
except sqlite3.Error as e:
logger.error(f"[{device_id}] Failed to update protocol_type on connect: {e}")
# Create shared acknowledgment state for sender/receiver communication
ack = DeviceAcknowledgment()
# Create tasks for the new connection
sender_task = asyncio.create_task(
sender(websocket, device_id, db_conn, ack), name=f"ws_sender_{device_id}"
)
receiver_task = asyncio.create_task(
receiver(websocket, device_id, db_conn), name=f"ws_receiver_{device_id}"
)
# Register the new connection, which handles cleanup of any old one
await connection_manager.register(
device_id, websocket, ack, sender_task, receiver_task
)
try:
done, pending = await asyncio.wait(
[sender_task, receiver_task], return_when=asyncio.FIRST_COMPLETED
)
for task in done:
try:
task.result()
except WebSocketDisconnect:
logger.info(f"WebSocket disconnected for device {device_id}")
break
except Exception as e:
logger.error(f"WebSocket task failed for device {device_id}: {e}")
break
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
finally:
# Unregister the connection and signal cleanup completion
await connection_manager.unregister(device_id, websocket)

View File

@@ -1,170 +0,0 @@
#!/usr/bin/env python3
import os
import sys
from typing import Any
import copy
import click
import uvicorn
import logging
from logging.config import dictConfig
from uvicorn.main import main as uvicorn_cli
from uvicorn.config import LOGGING_CONFIG
from tronbyt_server.config import get_settings
from tronbyt_server.startup import run_once
from tronbyt_server.sync import get_sync_manager
logger = logging.getLogger("tronbyt_server.run")
def main() -> None:
"""
Run the Uvicorn server with programmatic configuration that respects CLI overrides.
This script establishes a clear order of precedence for settings:
1. Command-line arguments (e.g., --port 9000)
2. Environment variables (e.g., PORT=9000)
3. Application-specific defaults defined in this script (e.g., disable pings)
4. Uvicorn's built-in defaults (e.g., host='127.0.0.1')
"""
# Load settings from config.py (which handles .env files)
settings = get_settings()
# 1. Let Uvicorn parse CLI args and env vars to establish a baseline config.
# This captures user intent and Uvicorn's own defaults.
try:
ctx = uvicorn_cli.make_context(
info_name=sys.argv[0], args=sys.argv[1:], resilient_parsing=True
)
except click.exceptions.Exit as e:
# Handle cases like --version or --help where click wants to exit.
sys.exit(e.exit_code)
# This is our working config, starting with everything Uvicorn has parsed.
config = ctx.params
# 2. Define our application-specific defaults.
# These will only be applied if not already set by the user (via CLI/env).
port_str = os.environ.get("TRONBYT_PORT", os.environ.get("PORT", "8000"))
try:
port = int(port_str)
except ValueError:
port = 8000
is_production = settings.PRODUCTION == "1"
app_defaults: dict[str, Any] = {
"app": "tronbyt_server.main:app",
"host": os.environ.get("TRONBYT_HOST", "::"),
"port": port,
"log_level": settings.LOG_LEVEL.lower(),
"forwarded_allow_ips": "*",
"ws_ping_interval": None, # Our most critical default: disable pings
}
if is_production:
app_defaults["workers"] = int(os.environ.get("WEB_CONCURRENCY", "2"))
else:
app_defaults["reload"] = True
# 3. Intelligently merge our defaults into the config.
# We only apply our default if the user hasn't provided the setting.
for key, value in app_defaults.items():
source = ctx.get_parameter_source(key)
if source not in (
click.core.ParameterSource.COMMANDLINE,
click.core.ParameterSource.ENVIRONMENT,
):
config[key] = value
# Custom logging configuration
app_log_level_str = config.get("log_level", "info").upper()
# Map string log levels to integer equivalents
log_level_map = logging.getLevelNamesMapping()
app_log_level_num = log_level_map.get(app_log_level_str)
if app_log_level_num is None:
logger.warning(
f"Invalid log level '{app_log_level_str}' specified. Defaulting to INFO."
)
app_log_level_num = logging.INFO
# Uvicorn's access log level should never be more verbose than INFO.
# Higher number means less verbose.
uvicorn_log_level_num = max(app_log_level_num, logging.INFO)
uvicorn_log_level_str = logging.getLevelName(uvicorn_log_level_num)
log_config = copy.deepcopy(LOGGING_CONFIG)
# Configure formatters
log_config["formatters"]["default"]["fmt"] = (
"%(asctime)s %(levelprefix)s [%(name)s] %(message)s" # For tronbyt_server
)
log_config["formatters"]["access"]["fmt"] = (
'%(asctime)s %(levelprefix)s [access] %(client_addr)s - "%(request_line)s" %(status_code)s' # For uvicorn.access
)
log_config["formatters"]["uvicorn_no_name"] = {
"()": "uvicorn.logging.DefaultFormatter",
"fmt": "%(asctime)s %(levelprefix)s [uvicorn] %(message)s",
"use_colors": config.get("use_colors"),
}
# Configure handlers
log_config["handlers"]["default"]["level"] = app_log_level_str
log_config["handlers"]["access"]["level"] = uvicorn_log_level_str
log_config["handlers"]["uvicorn_error_handler"] = {
"formatter": "uvicorn_no_name",
"class": "logging.StreamHandler",
"stream": "ext://sys.stderr",
"level": app_log_level_str,
}
# Configure loggers
log_config["loggers"][""] = {
"handlers": ["default"],
"level": app_log_level_str,
}
log_config["loggers"]["uvicorn"] = {
"handlers": ["uvicorn_error_handler"],
"level": app_log_level_str,
"propagate": False,
}
log_config["loggers"]["uvicorn.access"] = {
"handlers": ["access"],
"level": uvicorn_log_level_str,
"propagate": False,
}
log_config["loggers"]["uvicorn.error"] = {
"handlers": ["uvicorn_error_handler"],
"level": app_log_level_str,
"propagate": False,
}
# Apply the logging configuration immediately
dictConfig(log_config)
# Run startup tasks that should only be executed once
run_once()
# The 'app' argument must be positional for uvicorn.run()
app = config.pop("app")
config["log_config"] = log_config
# Announce server startup using the final, merged configuration
startup_message = "Starting server"
if config.get("reload"):
startup_message += " with auto-reload"
if config.get("workers"):
startup_message += f" with {config['workers']} workers"
logger.info(startup_message)
# The sync manager needs to be initialized in the parent process and shut down
# gracefully. Using a context manager is the cleanest way to ensure this.
with get_sync_manager():
uvicorn.run(app, **config)
if __name__ == "__main__":
main()

View File

@@ -1,110 +0,0 @@
"""One-time startup tasks for the application."""
import logging
import shutil
import sqlite3
from datetime import datetime
from pathlib import Path
from tronbyt_server import db, firmware_utils, system_apps
from tronbyt_server.config import get_settings
logger = logging.getLogger(__name__)
def backup_database(db_file: str) -> None:
"""Create a timestamped backup of the SQLite database."""
db_path = Path(db_file)
if not db_path.exists():
logger.warning(f"Database file does not exist, skipping backup: {db_file}")
return
# Create backup directory if it doesn't exist
backup_dir = db_path.parent / "backups"
backup_dir.mkdir(exist_ok=True)
# Get schema version from database
schema_version = "unknown"
try:
with sqlite3.connect(db_file) as conn:
cursor = conn.cursor()
cursor.execute("SELECT schema_version FROM meta LIMIT 1")
row = cursor.fetchone()
if row:
schema_version = str(row[0])
except sqlite3.Error as e:
logger.warning(f"Could not retrieve schema version: {e}")
# Create timestamped backup filename with schema version
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_filename = f"{db_path.stem}_{timestamp}_v{schema_version}.db"
backup_path = backup_dir / backup_filename
try:
shutil.copy2(db_file, backup_path)
logger.info(f"Database backed up to: {backup_path}")
except (shutil.Error, OSError) as e:
logger.error(f"Failed to backup database: {e}")
def run_once() -> None:
"""Run tasks that should only be executed once at application startup."""
settings = get_settings()
logger = logging.getLogger(__name__)
logger.info("Running one-time startup tasks...")
try:
result = firmware_utils.update_firmware_binaries_subprocess(db.get_data_dir())
if result["success"]:
if result["action"] == "updated":
logger.info(f"Firmware updated: {result['message']}")
elif result["action"] == "skipped":
logger.info(f"Firmware check: {result['message']}")
else:
logger.warning(f"Firmware update failed (non-fatal): {result['message']}")
except Exception as e:
logger.warning(f"Failed to update firmware during startup (non-fatal): {e}")
# Backup the database before initializing (only in production)
# Skip system apps update in dev mode
if settings.PRODUCTION == "1":
system_apps.update_system_repo(db.get_data_dir())
backup_database(settings.DB_FILE)
else:
logger.info("Skipping system apps update and database backup (dev mode)")
# Warn if single-user auto-login is enabled
if settings.SINGLE_USER_AUTO_LOGIN == "1":
msg = """
======================================================================
⚠️ SINGLE-USER AUTO-LOGIN MODE IS ENABLED
======================================================================
Authentication is DISABLED for private network connections!
This mode automatically logs in the single user without password.
SECURITY REQUIREMENTS:
✓ Only works when exactly 1 user exists
✓ Only works from trusted networks:
- Localhost (127.0.0.1, ::1)
- Private IPv4 networks (192.168.x.x, 10.x.x.x, 172.16.x.x)
- IPv6 local ranges (Unique Local Addresses fc00::/7, commonly fd00::/8)
- IPv6 link-local (fe80::/10)
✓ Public IP connections still require authentication
To disable: Set SINGLE_USER_AUTO_LOGIN=0 in your .env file
======================================================================
""".strip()
logger.warning(msg)
# Initialize, migrate, and vacuum database
try:
(Path(settings.DB_FILE).parent).mkdir(parents=True, exist_ok=True)
with sqlite3.connect(settings.DB_FILE) as conn:
db.init_db(conn)
db.vacuum(conn)
except Exception as e:
logger.error(f"Could not initialize or vacuum database: {e}", exc_info=True)
logger.info("One-time startup tasks complete.")

View File

@@ -1,263 +0,0 @@
/* Add App Page - Unique Styles Only */
/* Loading indicator */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #4CAF50;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Hide loading indicator when content is loaded */
.content-loaded .loading-container {
display: none;
}
/* Optimized grid layout */
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
contain: layout style paint;
}
.app-item {
border: 1px solid #d8d8d8;
padding: 10px;
text-align: center;
cursor: pointer;
position: relative;
contain: layout style paint;
will-change: transform;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.app-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.app-img {
position: relative;
width: 100%;
aspect-ratio: 2 / 1;
overflow: hidden;
border-radius: 4px;
}
.lazy-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s ease;
z-index: 2;
}
.lazy-image.loaded {
opacity: 1;
}
.app-item.selected {
border-color: #4CAF50;
box-shadow: 0 0 0 2px #4CAF50;
}
.app-item.installed {
border-color: #ffa500;
}
.installed-badge {
position: absolute;
top: 5px;
right: 5px;
background-color: #ff9800;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
z-index: 10;
}
.supports-2x-badge {
position: absolute;
top: 5px;
left: 5px;
background-color: #2196F3;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
z-index: 10;
}
.app-item.broken-app {
border: 2px solid #f44336;
background-color: rgba(244, 67, 54, 0.05);
cursor: not-allowed;
}
.app-item.broken-app:hover {
background-color: rgba(244, 67, 54, 0.1);
transform: none;
}
.filter-sort-controls select {
padding: 2px;
border: 1px solid #ddd;
border-radius: 3px;
}
.filter-sort-container {
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
.filter-sort-wrapper {
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.filter-label {
display: flex;
align-items: center;
gap: 5px;
}
.filter-checkbox {
margin: 0 !important;
}
.filter-sort-controls {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
.filter-sort-controls-inner {
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.control-label {
display: flex;
align-items: center;
gap: 5px;
}
.filter-sort-controls input[type="search"] {
margin-bottom: 0;
border: 1px solid #ddd;
border-radius: 3px;
}
.filter-sort-controls .control-checkbox {
margin: 0;
}
/* Performance optimizations */
.app-grid {
transform: translateZ(0); /* Force hardware acceleration */
backface-visibility: hidden;
}
.app-item {
transform: translateZ(0); /* Force hardware acceleration */
backface-visibility: hidden;
}
/* Reduce paint operations during scrolling */
.app-item img {
transform: translateZ(0);
will-change: transform;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Optimize text rendering */
.app-item p {
text-rendering: optimizeSpeed;
font-smooth: never;
-webkit-font-smoothing: subpixel-antialiased;
}
/* Use higher specificity to override global link styles without !important */
.app-item a.delete-upload-btn {
display: inline-block;
background-color: #f44336;
color: white;
padding: 5px 10px;
border-radius: 3px;
text-decoration: none;
font-size: 12px;
font-weight: bold;
margin-top: 8px;
transition: background-color 0.2s;
position: relative;
z-index: 5;
}
.app-item a.delete-upload-btn:hover {
background-color: #d32f2f;
text-decoration: none;
color: white;
}
.app-item a.delete-upload-btn:visited {
color: white;
}
.app-item a.delete-upload-btn:active {
color: white;
}
.app-item a.delete-upload-btn:focus {
color: white;
outline: 2px solid #d32f2f;
outline-offset: 2px;
}
/* App group styling */
.app-group h3 {
display: inline-block;
margin-right: 10px;
}
.app-group span {
font-size: 14px;
color: #888;
}
.app-group a {
color: #4CAF50;
text-decoration: none;
}

View File

@@ -1,286 +0,0 @@
/* Base Template Additional Styles */
/* Global responsive styles */
.w3-card-4 {
margin: 10px;
max-width: 100%;
}
/* Make inputs more touch-friendly on mobile */
input[type="range"] {
width: 100%;
max-width: 300px;
height: 30px;
/* Larger touch target */
}
/* Responsive buttons */
.w3-button {
margin: 5px;
display: inline-block;
width: auto;
border-radius: 8px;
}
/* Left side hamburger menu styles */
.hamburger {
display: none;
flex-direction: column;
cursor: pointer;
padding: 0.5rem;
background: none;
border: none;
z-index: 1002;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
order: -1; /* Ensure it appears first */
}
.hamburger span {
width: 25px;
height: 3px;
background: var(--page-text);
margin: 3px 0;
transition: 0.3s;
border-radius: 2px;
}
.hamburger.active {
display: none;
}
/* Mobile menu overlay */
.mobile-menu-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.mobile-menu-overlay.active {
display: block;
}
/* Left side sliding panel */
.mobile-menu {
position: fixed;
top: 0;
left: -300px;
width: 300px;
height: 100vh;
background: var(--content-bg);
z-index: 1001;
padding: 1rem;
box-sizing: border-box;
overflow-y: auto;
transition: left 0.3s ease-in-out;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
.mobile-menu.active {
left: 0;
}
.mobile-menu-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.mobile-menu h1 {
margin: 0;
color: var(--header-text-color);
font-size: 1.2em;
}
.mobile-menu-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--page-text);
cursor: pointer;
padding: 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.mobile-menu ul {
list-style: none;
padding: 0;
margin: 0;
}
.mobile-menu ul li {
margin-bottom: 0.5rem;
}
.mobile-menu ul li a {
display: block;
padding: 1rem;
color: var(--link-color);
text-decoration: none;
border-radius: 8px;
transition: background-color 0.2s;
border: 1px solid var(--border-color);
}
.mobile-menu ul li a:hover {
background: var(--secondary-color);
}
.mobile-menu .theme-toggle-container {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.mobile-menu .theme-toggle {
justify-content: center;
margin-left: 0;
padding: 1rem;
}
/* Media queries for different screen sizes */
@media screen and (max-width: 768px) {
/* General mobile improvements */
body {
padding: 0.5rem;
}
.content {
padding: 0 0.5rem 0.5rem;
}
/* Show hamburger menu on mobile */
.hamburger {
display: flex;
}
/* Hide desktop navigation on mobile */
nav ul {
display: none;
}
/* Keep nav title visible and centered */
nav h1 {
flex: 1;
margin: 0;
text-align: center;
}
/* Ensure proper spacing between hamburger and title */
nav {
gap: 0.5rem;
}
}
@media screen and (max-width: 600px) {
.w3-button {
width: 100%;
margin: 5px 0;
}
h1 {
font-size: 1.5em;
}
.w3-padding {
padding: 8px !important;
}
}
#brightness {
width: 100%;
max-width: 300px;
margin: 10px 0;
}
#default_interval {
width: 100%;
max-width: 300px;
margin: 10px 0;
}
/* Auto-Login Mode Indicator */
.auto-login-indicator {
display: inline-block;
margin-right: 0.3rem;
font-size: 0.9rem;
opacity: 0.8;
}
/* Auto-Login Warning Banner (for register_owner page) */
.auto-login-warning-banner {
background: linear-gradient(135deg, #ff6b6b 0%, #ff8e53 100%);
color: white;
padding: 1rem;
text-align: center;
font-weight: bold;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
border-bottom: 4px solid #d63031;
border-radius: 8px;
margin-bottom: 1.5rem;
animation: pulse-warning 2s ease-in-out infinite;
}
.auto-login-warning-content {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
font-size: 1.2rem;
letter-spacing: 1px;
text-transform: uppercase;
}
.auto-login-warning-icon {
font-size: 1.5rem;
animation: bounce-warning 1s ease-in-out infinite;
}
.auto-login-warning-subtext {
margin-top: 0.5rem;
font-size: 0.9rem;
font-weight: normal;
opacity: 0.95;
}
@keyframes pulse-warning {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.85;
}
}
@keyframes bounce-warning {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@media screen and (max-width: 768px) {
.auto-login-warning-content {
font-size: 1rem;
gap: 0.5rem;
}
.auto-login-warning-icon {
font-size: 1.2rem;
}
.auto-login-warning-subtext {
font-size: 0.8rem;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,207 +0,0 @@
/* Common Styles - Shared across multiple pages */
/* Visibility classes */
.hidden {
display: none;
opacity: 0;
transition: opacity 0.5s ease-in-out;
}
.visible {
display: block;
opacity: 1;
transition: opacity 0.5s ease-in-out;
}
/* Layout containers */
.flex-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 20px;
}
.button-container {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.button-group {
display: flex;
align-items: center;
gap: 10px;
}
.button-separator {
color: var(--post-about-text);
font-weight: bold;
margin: 0 5px;
}
.toggle-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* Form styles */
.form-table {
border-spacing: 0 15px;
width: 100%;
table-layout: fixed;
}
.form-table td {
vertical-align: top;
padding: 5px;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 0;
}
.form-table label {
font-weight: bold;
display: block;
margin-bottom: 5px;
}
.form-table small {
color: var(--post-about-text);
display: block;
margin-top: 5px;
}
.form-table select {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.form-table select option {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
padding: 4px 8px;
}
.form-table ul {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
}
.form-table ul li {
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
padding: 4px 8px;
}
.form-table div[style*="listStyleType"] {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
}
.form-control {
width: 100%;
max-width: 100%;
padding: 8px;
border: 1px solid var(--input-border);
border-radius: 4px;
font-size: 14px;
background-color: var(--input-bg);
color: var(--input-text);
box-sizing: border-box;
}
/* App preview responsive styling removed - handled by manager.css for app cards */
/* Brightness button panel styles */
.brightness-panel {
display: flex;
gap: 6px;
margin-top: 8px;
margin-bottom: 15px;
max-width: 300px;
}
.brightness-btn {
flex: 0 0 40px;
padding: 8px 4px;
border: 2px solid #ccc;
cursor: pointer;
border-radius: 4px;
font-weight: bold;
font-size: 14px;
}
/* Light theme - progressively brighter backgrounds */
.brightness-btn[data-brightness="0"] { background-color: #333; color: white; border-color: #222; }
.brightness-btn[data-brightness="1"] { background-color: #666; color: white; border-color: #555; }
.brightness-btn[data-brightness="2"] { background-color: #888; color: white; border-color: #777; }
.brightness-btn[data-brightness="3"] { background-color: #aaa; color: white; border-color: #999; }
.brightness-btn[data-brightness="4"] { background-color: #ccc; color: #333; border-color: #bbb; }
.brightness-btn[data-brightness="5"] { background-color: #eee; color: #333; border-color: #ddd; }
.brightness-btn:hover {
transform: scale(1.05);
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
transition: transform 0.2s, box-shadow 0.2s;
}
.brightness-btn.active {
border-color: #377ba8;
border-width: 3px;
box-shadow: 0 0 8px rgba(55, 123, 168, 0.5);
}
/* Dark theme - progressively brighter backgrounds */
[data-theme="dark"] .brightness-btn[data-brightness="0"] { background-color: #1a1a1a; color: #666; border-color: #222; }
[data-theme="dark"] .brightness-btn[data-brightness="1"] { background-color: #333; color: #aaa; border-color: #444; }
[data-theme="dark"] .brightness-btn[data-brightness="2"] { background-color: #555; color: #ccc; border-color: #666; }
[data-theme="dark"] .brightness-btn[data-brightness="3"] { background-color: #777; color: #eee; border-color: #888; }
[data-theme="dark"] .brightness-btn[data-brightness="4"] { background-color: #999; color: #fff; border-color: #aaa; }
[data-theme="dark"] .brightness-btn[data-brightness="5"] { background-color: #bbb; color: #fff; border-color: #ccc; }
[data-theme="dark"] .brightness-btn:hover {
transform: scale(1.05);
box-shadow: 0 2px 4px rgba(255,255,255,0.2);
}
[data-theme="dark"] .brightness-btn.active {
border-color: #5a9cd1;
border-width: 3px;
box-shadow: 0 0 8px rgba(90, 156, 209, 0.5);
}
/* Mobile responsive adjustments */
@media screen and (max-width: 768px) {
.flex-container {
flex-direction: column;
align-items: stretch;
}
.button-group {
justify-content: center;
margin-bottom: 10px;
}
.toggle-buttons {
justify-content: center;
}
.form-table {
font-size: 0.9em;
}
.form-table td {
padding: 8px 4px;
}
/* App preview responsive styling removed - handled by manager.css for app cards */
}

View File

@@ -1,54 +0,0 @@
/* Config App Page - Unique Styles Only */
.button-separator {
color: #666; /* Override common color */
}
.palette {
text-align: center;
padding-bottom: 10px;
}
.palette button {
background-color: var(--color);
border-radius: 100%;
margin: 2px;
padding: 10px;
}
.palette button:hover {
background-color: var(--color) !important;
filter: brightness(1.25);
}
.secret-input-container {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.secret-input-container .form-control {
flex: 1 1 auto;
}
.secret-toggle-button {
cursor: pointer;
font-size: 14px;
border-radius: 4px;
display: inline-flex;
align-items: center;
}
.secret-toggle-button:focus-visible {
outline: 2px solid #2196F3;
outline-offset: 2px;
}
.api-usage-details.collapsed {
display: none;
}
@media screen and (max-width: 600px) {
.palette button {
width: auto;
padding: 20px;
}
}

View File

@@ -1,150 +0,0 @@
/* Firmware Page - Unique Styles Only */
.page-title {
margin: 0;
font-size: 1.5em;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 10px;
width: 100%;
}
.firmware-info {
margin-bottom: 20px;
padding: 15px;
background-color: var(--flash-bg);
border-left: 4px solid var(--flash-border);
border-radius: 4px;
color: var(--flash-text);
}
.firmware-management-btn {
padding: 8px 16px;
font-size: 14px;
}
.connection-type-container {
display: flex;
gap: 15px;
align-items: center;
margin-bottom: 15px;
}
.connection-type-container label {
display: flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.url-input-container {
display: flex;
gap: 10px;
align-items: center;
}
.url-input-container input {
flex: 1;
padding: 8px;
}
.wifi-input-container {
display: flex;
gap: 10px;
align-items: center;
}
.wifi-input-container input {
flex: 1;
padding: 8px;
}
.swap-colors-container {
display: flex;
gap: 10px;
align-items: center;
}
/* Device settings form styles */
.device-settings-section {
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 20px;
margin-bottom: 20px;
background: var(--content-bg);
}
.device-settings-section h2 {
margin-bottom: 20px;
font-size: 1.5rem;
color: var(--page-text);
margin-top: 0;
}
.device-settings-section h3 {
margin-bottom: 15px;
font-size: 1.2rem;
color: var(--page-text);
margin-top: 0;
}
.device-settings-section-divider {
border-top: 1px solid var(--border-color);
padding-top: 15px;
margin-top: 15px;
}
.device-settings-label {
display: block;
margin-bottom: 5px;
font-size: 0.9em;
color: var(--page-text);
}
.device-settings-input {
width: 100%;
padding: 8px;
border: 1px solid var(--input-border);
border-radius: 4px;
background-color: var(--input-bg);
color: var(--input-text);
}
.device-settings-table {
width: 100%;
border-spacing: 0 10px;
}
.device-settings-table td:first-child {
width: 30%;
vertical-align: top;
padding-right: 15px;
}
.device-settings-table td:last-child {
width: 70%;
}
.small-text {
color: var(--post-about-text);
font-size: 0.8em;
display: block;
margin-top: 5px;
}
.config-management-container {
display: flex;
gap: 10px;
align-items: center;
}
.config-management-btn {
padding: 8px 16px;
font-size: 14px;
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,89 +0,0 @@
/* Partials CSS - Device and App Cards */
/* Device Card Styles */
.device-link {
text-decoration: none;
color: inherit;
}
.pinned-badge {
display: inline-block;
margin-left: 10px;
padding: 4px 10px;
background-color: #ff9800;
color: white;
border-radius: 4px;
font-size: 0.7em;
font-weight: bold;
vertical-align: middle;
}
.auto-refresh-container {
display: inline-block;
margin-left: 10px;
}
.auto-refresh-label {
vertical-align: middle;
margin-left: 5px;
}
/* Switch styles for auto-refresh toggle */
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
transition: .4s;
}
input:checked + .slider {
background-color: #2196F3;
}
input:focus + .slider {
box-shadow: 0 0 1px #2196F3;
}
input:checked + .slider:before {
transform: translateX(26px);
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
.device-info-box {
margin-top: 10px;
}

View File

@@ -1,6 +0,0 @@
/*!
* Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2025 Fonticons, Inc.
*/
:host,:root{--fa-family-classic:"Font Awesome 7 Free";--fa-font-solid:normal 900 1em/1 var(--fa-family-classic);--fa-style-family-classic:var(--fa-family-classic)}@font-face{font-family:"Font Awesome 7 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2)}.fas{--fa-style:900}.fa-classic,.fas{--fa-family:var(--fa-family-classic)}.fa-solid{--fa-style:900}

View File

@@ -1,190 +0,0 @@
/* Define :root variables for light theme (default) */
:root {
--page-bg: #f5f5f5; /* Slightly darker for better contrast */
--page-text: #1a1a1a; /* Darker text for better contrast */
--content-bg: #ffffff;
--primary-color: #2563eb; /* Improved blue with better contrast */
--secondary-color: #e5e7eb; /* Better contrast than lightgray */
--flash-bg: #dbeafe; /* Light blue with better contrast */
--flash-border: #2563eb;
--flash-text: #1e40af; /* Darker blue text for better contrast */
--link-color: #2563eb;
--header-text-color: #2563eb;
--post-about-text: #6b7280; /* Better contrast than slategray */
--input-bg: #ffffff;
--input-text: #1a1a1a;
--input-border: #d1d5db; /* Better contrast than #ccc */
--button-bg: #f3f4f6; /* Slightly darker for better contrast */
--button-text: #1a1a1a;
--submit-bg: #2563eb;
--submit-text: #ffffff;
--danger-text: #dc2626; /* Better contrast red */
--enabled-text: #059669; /* Better contrast green */
--disabled-text: #dc2626; /* Consistent with danger */
--app-img-bg: #000000;
--app-img-border: #d1d5db; /* Better contrast */
--select-bg: #ffffff;
--select-text: #1a1a1a; /* Better contrast */
--select-border: #d1d5db;
--border-color: #e5e7eb; /* New variable for consistent borders */
--shadow-color: rgba(0, 0, 0, 0.1); /* New variable for shadows */
}
/* Define variables for dark theme */
[data-theme="dark"] {
--page-bg: #0f172a; /* Darker background for better contrast */
--page-text: #f8fafc; /* High contrast white text */
--content-bg: #1e293b; /* Darker content background */
--primary-color: #60a5fa; /* Brighter blue for dark mode */
--secondary-color: #475569; /* Better contrast secondary */
--flash-bg: #1e3a8a; /* Dark blue background for flash messages */
--flash-border: #60a5fa;
--flash-text: #dbeafe; /* Light blue text for flash messages */
--link-color: #60a5fa;
--header-text-color: #60a5fa;
--post-about-text: #94a3b8; /* Better contrast gray */
--input-bg: #334155; /* Darker input background */
--input-text: #f8fafc;
--input-border: #64748b; /* Better contrast border */
--button-bg: #475569; /* Darker button background */
--button-text: #f8fafc;
--submit-bg: #2563eb; /* Keep same blue for consistency */
--submit-text: #ffffff;
--danger-text: #f87171; /* Brighter red for dark mode */
--enabled-text: #34d399; /* Brighter green for dark mode */
--disabled-text: #f87171; /* Consistent with danger */
--app-img-bg: #000000;
--app-img-border: #64748b; /* Better contrast */
--select-bg: #334155;
--select-text: #f8fafc;
--select-border: #64748b;
--border-color: #475569; /* Dark mode border color */
--shadow-color: rgba(0, 0, 0, 0.3); /* Darker shadows for dark mode */
}
/* Apply variables to existing styles */
html { font-family: sans-serif; background: var(--page-bg); color: var(--page-text); padding: 1rem; }
body { max-width: 960px; margin: 0 auto; background: var(--content-bg); color: var(--page-text); }
h1 { font-family: sans-serif; color: var(--header-text-color); margin: 1rem 0; }
a { color: var(--link-color); }
hr { border: none; border-top: 1px solid var(--secondary-color); }
nav { background: var(--secondary-color); display: flex; align-items: center; padding: 0 0.5rem; }
nav h1 { flex: auto; margin: 0; }
nav h1 a { text-decoration: none; padding: 0.25rem 0.5rem; color: var(--header-text-color); } /* Ensure nav h1 link uses header text color or link color */
nav ul { display: flex; list-style: none; margin: 0; padding: 0; }
nav ul li a, nav ul li span, nav header .action { display: block; padding: 0.5rem; color: var(--link-color); } /* Ensure nav links use link color */
.content { padding: 0 1rem 1rem; }
.content > header { border-bottom: 1px solid var(--secondary-color); display: flex; align-items:flex-end; }
.content > header h1 { flex: auto; margin: 1rem 0 0.25rem 0; } /* Uses h1 style */
.flash { margin: 1em 0; padding: 1em; background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-text); }
.post > header { display: flex; align-items: flex-end; font-size: 0.85em; } /* Text color will be inherited from body */
.post > header > div:first-of-type { flex: auto; }
.post > header h1 { font-size: 1.5em; margin-bottom: 0; } /* Uses h1 style */
.post .about { color: var(--post-about-text); font-style: italic; }
.post .body { white-space: pre-line; } /* Text color will be inherited */
.content:last-child { margin-bottom: 0; }
.content form { margin: 1em 0; display: flex; flex-direction: column; } /* Text color will be inherited */
.content label { font-weight: bold; margin-bottom: 0.5em; color: var(--page-text); } /* Ensure labels use page text */
.content input, .content button, .content textarea {
margin-bottom: 1em;
background: var(--input-bg);
color: var(--input-text);
border: 1px solid var(--input-border); /* Add border for consistency */
padding: 0.5em; /* Add some padding */
}
.content button { /* Style generic buttons */
background: var(--button-bg);
color: var(--button-text);
border: 1px solid var(--input-border); /* Or a specific button border variable */
}
.content textarea { min-height: 12em; resize: vertical; }
input.danger { color: var(--danger-text); background: transparent; border: none; } /* Make danger text inputs stand out */
input[type=submit] {
align-self: start;
min-width: 10em;
background: var(--submit-bg);
color: var(--submit-text);
border: none; /* Submit buttons often don't have a border or have a matching one */
}
/* Use consistent classes: .text-enabled and .text-disabled */
.text-enabled { color: var(--enabled-text); }
.text-disabled { color: var(--disabled-text); }
/* app-img styles with variables */
.app-img {
position: relative;
background: var(--app-img-bg);
overflow: hidden;
border: 1px solid var(--app-img-border);
border-radius: 0.5rem;
}
.app-img img {
aspect-ratio: 2 / 1;
background: var(--app-img-bg); /* Match parent background */
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
-webkit-mask-image: url('/v0/dots');
-webkit-mask-repeat: no-repeat;
-webkit-mask-size: cover;
mask-image: url('/v0/dots');
mask-repeat: no-repeat;
mask-size: cover;
width: 100%;
height: 100%;
}
.app-img.is-2x img {
-webkit-mask-image: url('/v0/dots?w=128&h=64&r=0.4');
mask-image: url('/v0/dots?w=128&h=64&r=0.4');
}
/* Skeleton loader */
.skeleton-loader {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, #0009 25%, #fff3 50%, #0009 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Hide skeleton when image loads */
.app-img img.loaded + .skeleton-loader {
display: none;
}
/* Theme Toggle Styles - now using variables */
.theme-toggle-container {
display: inline-block;
list-style-type: none;
}
.theme-toggle {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 0.5rem;
}
.theme-toggle label {
margin-right: 6px;
font-size: 0.9em;
color: var(--page-text); /* Ensure label color matches theme */
}
.theme-toggle select {
padding: 3px 6px;
border-radius: 4px;
border: 1px solid var(--select-border);
background-color: var(--select-bg);
font-size: 0.9em;
color: var(--select-text);
}
/* Theming is now handled by [data-theme] attribute set via JavaScript */

View File

@@ -1,233 +0,0 @@
/* Update Device Page - Unique Styles Only */
.page-title {
margin: 0;
font-size: 1.5em;
}
.header-buttons {
display: flex;
gap: 8px;
align-items: center;
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 10px;
width: 100%;
}
.form-container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: var(--content-bg);
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
}
.form-container h2 {
margin-bottom: 20px;
font-size: 1.5rem;
color: var(--page-text);
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
font-weight: bold;
margin-bottom: 5px;
color: var(--page-text);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px;
border: 1px solid var(--input-border);
border-radius: 4px;
font-size: 1rem;
background-color: var(--input-bg);
color: var(--input-text);
}
.form-group input[type="range"] {
width: calc(100% - 50px);
display: inline-block;
vertical-align: middle;
}
.form-group output {
display: inline-block;
width: 50px;
text-align: center;
font-weight: bold;
color: var(--page-text);
}
.form-actions {
display: flex;
justify-content: space-between;
margin-top: 20px;
}
.form-actions .w3-button {
padding: 10px 20px;
font-size: 1rem;
}
.config-links {
margin-top: 30px;
text-align: center;
}
.config-links a {
margin: 0 10px;
}
small {
color: var(--post-about-text);
}
/* Device settings form styles */
.device-settings-section {
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 20px;
margin-bottom: 20px;
}
.device-settings-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.device-settings-row > div {
flex: 1;
}
.device-settings-label {
display: block;
margin-bottom: 5px;
font-size: 0.9em;
}
.device-settings-input {
width: 100%;
padding: 8px;
}
textarea.device-example-command-input {
width: 100%;
font-family: monospace;
font-size: 0.9em;
min-height: auto;
}
.device-settings-section-divider {
border-top: 1px solid var(--border-color);
padding-top: 15px;
}
.url-input-container {
display: flex;
gap: 10px;
align-items: center;
}
.url-input-container input {
flex: 1;
padding: 8px;
}
.url-reset-btn {
padding: 8px 12px;
font-size: 12px;
}
.device-settings-table {
width: 100%;
border-spacing: 0 10px;
}
.device-settings-table td:first-child {
width: 30%;
vertical-align: top;
padding-right: 15px;
}
.device-settings-table td:last-child {
width: 70%;
}
.range-container {
display: flex;
align-items: center;
gap: 10px;
}
.range-container input[type="range"] {
flex: 1;
}
.range-output {
min-width: 30px;
text-align: center;
font-weight: bold;
}
.select-container {
width: 300px;
padding: 8px;
}
.small-text {
color: var(--post-about-text);
font-size: 0.8em;
display: block;
margin-top: 5px;
}
.notes-input {
width: 400px;
padding: 8px;
}
.config-management-container {
display: flex;
gap: 10px;
align-items: center;
}
.config-management-btn {
padding: 8px 16px;
font-size: 14px;
}
/* Override brightness styles for update page (dark theme default) */
.brightness-btn[data-brightness="0"] { background-color: #1a1a1a; color: #666; border-color: #222; }
.brightness-btn[data-brightness="1"] { background-color: #333; color: #aaa; border-color: #444; }
.brightness-btn[data-brightness="2"] { background-color: #555; color: #ccc; border-color: #666; }
.brightness-btn[data-brightness="3"] { background-color: #777; color: #eee; border-color: #888; }
.brightness-btn[data-brightness="4"] { background-color: #999; color: #fff; border-color: #aaa; }
.brightness-btn[data-brightness="5"] { background-color: #bbb; color: #fff; border-color: #ccc; }
.brightness-btn:hover {
transform: scale(1.05);
box-shadow: 0 2px 4px rgba(255,255,255,0.2);
}
.brightness-btn.active {
border-color: #377ba8;
border-width: 3px;
box-shadow: 0 0 8px rgba(55, 123, 168, 0.5);
}

View File

@@ -1,64 +0,0 @@
/* Update App Page - Unique Styles Only */
.app-preview {
text-align: center;
margin-bottom: 20px;
}
.schedule-section {
border: 1px solid var(--border-color);
border-radius: 5px;
padding: 15px;
margin: 15px 0;
background-color: var(--secondary-color);
}
.custom-recurrence-section {
border: 1px solid var(--border-color);
border-radius: 5px;
padding: 15px;
margin: 15px 0;
background-color: var(--flash-bg);
}
.api-section {
margin-top: 30px;
padding: 20px;
background-color: var(--content-bg);
border-radius: 8px;
border-left: 4px solid var(--enabled-text);
border: 1px solid var(--border-color);
}
.api-section h2 {
margin-top: 0;
color: var(--enabled-text);
}
.api-section h3 {
color: var(--enabled-text);
font-size: 1.1em;
margin-bottom: 10px;
}
.api-section pre {
background-color: var(--page-bg);
padding: 15px;
border-radius: 4px;
overflow-x: auto;
color: var(--page-text);
font-size: 0.9em;
border: 1px solid var(--border-color);
}
.api-section p {
color: var(--page-text);
margin-bottom: 20px;
}
.api-section small {
color: var(--post-about-text);
font-size: 0.9em;
margin-top: 20px;
display: block;
}

View File

@@ -1,235 +0,0 @@
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
audio:not([controls]){display:none;height:0}[hidden],template{display:none}
a{background-color:transparent}a:active,a:hover{outline-width:0}
abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none}
code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible}
button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
button,input{overflow:visible}button,select{text-transform:none}
button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}
button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
[type=checkbox],[type=radio]{padding:0}
[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
[type=search]{-webkit-appearance:textfield;outline-offset:-2px}
[type=search]::-webkit-search-decoration{-webkit-appearance:none}
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
/* End extract */
html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden}
h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit}
.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap}
.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}
.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
.w3-main,#main{transition:margin-left .4s}
.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
.w3-bar .w3-button{white-space:normal}
.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
.w3-responsive{display:block;overflow-x:auto}
.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
@media (max-width:1205px){.w3-auto{max-width:95%}}
@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}
.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
.w3-display-position{position:absolute}
.w3-circle{border-radius:50%}
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
.w3-left{float:left!important}.w3-right{float:right!important}
.w3-button:hover{color:#000!important;background-color:#ccc!important}
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
.w3-hover-none:hover{box-shadow:none!important}
/* Colors */
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,90 +0,0 @@
function enableLocationSearch(inputElement, resultsElement, hiddenInputElement, onChangeCallback) {
let timeout = null;
const apiKey = '49863bd631b84a169b347fafbf128ce6';
function performSearch(query, isInitialSearch = false) {
if (query.length === 0) {
resultsElement.innerHTML = '';
return;
}
const encodedQuery = encodeURIComponent(query);
const url = `https://api.geoapify.com/v1/geocode/search?text=${encodedQuery}&apiKey=${apiKey}`;
fetch(url)
.then(response => response.json())
.then(data => {
resultsElement.innerHTML = '';
if (data.features && data.features.length > 0) {
const listItems = data.features.map(feature => {
const li = document.createElement('li');
const icon = document.createElement('i');
icon.className = 'fa-solid fa-location-dot';
icon.setAttribute('aria-hidden', 'true');
li.appendChild(icon);
li.appendChild(document.createTextNode(` ${feature.properties.formatted}`));
li.dataset.lat = feature.properties.lat;
li.dataset.lon = feature.properties.lon;
li.dataset.timezone = feature.properties.timezone?.name;
li.dataset.formatted = feature.properties.formatted;
// Use city first, fallback to locality, then county, then state, then country
li.dataset.locality = feature.properties.city || feature.properties.locality || feature.properties.county || feature.properties.state || feature.properties.country || feature.properties.formatted;
li.dataset.placeId = feature.properties.place_id;
li.addEventListener('click', function () {
inputElement.value = this.dataset.formatted;
resultsElement.innerHTML = ''; // Clear results after click
const locationData = {
locality: this.dataset.locality,
description: this.dataset.formatted,
timezone: this.dataset.timezone,
lat: this.dataset.lat,
lng: this.dataset.lon
};
if (this.dataset.placeId) {
locationData.place_id = this.dataset.placeId;
}
const hiddenValue = JSON.stringify(locationData);
hiddenInputElement.value = hiddenValue;
if (onChangeCallback) {
onChangeCallback(hiddenValue);
}
});
resultsElement.appendChild(li);
return li;
});
if (isInitialSearch) {
const exactMatchLi = listItems.find(li => li.dataset.formatted === query);
if (exactMatchLi) {
exactMatchLi.click();
} else if (listItems.length > 0) {
listItems[0].click();
}
}
} else {
resultsElement.innerHTML = `<li>{{ _('No results found') }}</li>`;
}
})
.catch(error => console.error('Error fetching location data:', error));
}
inputElement.addEventListener('input', function () {
clearTimeout(timeout);
const query = this.value.trim();
timeout = setTimeout(() => {
performSearch(query, false);
}, 300);
});
// Perform initial search if there's a value in the input field on load
// BUT only if there's no existing location data in the hidden field
const initialQuery = inputElement.value.trim();
const existingLocationData = hiddenInputElement.value.trim();
if (initialQuery.length > 0 && (!existingLocationData || existingLocationData === '{}')) {
performSearch(initialQuery, true);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,198 +0,0 @@
// tronbyt_server/static/js/theme.js
(function() {
const THEME_STORAGE_KEY = 'theme_preference';
const themeSelect = document.getElementById('theme-select');
const mobileThemeSelect = document.getElementById('mobile-theme-select');
const docElement = document.documentElement; // Usually <html>
let mediaQueryListener = null;
function applyTheme(theme) {
if (theme === 'system') {
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
docElement.setAttribute('data-theme', systemPrefersDark ? 'dark' : 'light');
} else {
docElement.setAttribute('data-theme', theme);
}
}
function storePreference(theme) {
localStorage.setItem(THEME_STORAGE_KEY, theme);
}
function savePreferenceToServer(theme) {
// Ensure this endpoint exists and is protected
fetch('/set_theme_preference', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// CSRF protection for this POST request relies on the SameSite=Lax cookie policy.
},
body: JSON.stringify({ theme: theme })
})
.then(response => {
if (!response.ok) {
console.error('Server responded with an error:', response.status, response.statusText);
// Try to parse as JSON, but provide a fallback if it's not
return response.text().then(text => {
try {
// Attempt to parse as JSON first, as the original code expected JSON error messages
const errJson = JSON.parse(text);
// Ensure it's an error structure we expect, otherwise treat as a generic error
if (errJson && (errJson.message || errJson.error)) {
return Promise.reject(errJson);
}
// If not a structured JSON error, or empty JSON, fall through to generic error
throw new Error(`Server error: ${response.status} ${response.statusText}. Response: ${text.substring(0, 100)}`);
} catch (e) {
// If JSON parsing fails, or it's not our expected error format,
// throw an error with the status text and part of the response
throw new Error(`Server error: ${response.status} ${response.statusText}. Response: ${text.substring(0, 100)}`);
}
});
}
return response.json();
})
.then(data => {
if (data.status !== 'success') {
console.error('Error saving theme preference:', data.message);
} else {
console.log('Theme preference saved to server:', theme);
}
})
.catch(error => console.error('Error saving theme preference:', error));
}
function handle2xAppImages () {
const APP_IMG_2X_WIDTH = 128;
const processImage = (img) => {
const applyClass = () => img.closest('.app-img')?.classList.toggle('is-2x', img.naturalWidth === APP_IMG_2X_WIDTH);
if (img.complete) {
applyClass();
} else {
img.addEventListener('load', applyClass, { once: true });
}
};
document.querySelectorAll('.app-img img').forEach(processImage);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType !== 1) return;
if (node.matches('.app-img img')) {
processImage(node);
} else {
node.querySelectorAll('.app-img img').forEach(processImage);
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
function handleSystemThemeChange(e) {
// This function is called when system theme changes.
// It should only re-apply the theme if 'system' is currently selected.
const currentTheme = (themeSelect && themeSelect.value) || (mobileThemeSelect && mobileThemeSelect.value);
if (currentTheme === 'system') {
applyTheme('system');
} else if (!themeSelect && !mobileThemeSelect) {
// For pages without the selector (e.g. login page for anonymous users)
// If a theme is in local storage, respect it. Otherwise, follow system.
const localTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (!localTheme || localTheme === 'system') {
applyTheme('system');
}
}
}
function setupSystemThemeListener() {
if (mediaQueryListener) { // Remove existing listener if any
mediaQueryListener.removeEventListener('change', handleSystemThemeChange);
}
mediaQueryListener = window.matchMedia('(prefers-color-scheme: dark)');
mediaQueryListener.addEventListener('change', handleSystemThemeChange);
}
function removeSystemThemeListener() {
if (mediaQueryListener) {
mediaQueryListener.removeEventListener('change', handleSystemThemeChange);
mediaQueryListener = null;
}
}
function initTheme() {
const localPreference = localStorage.getItem(THEME_STORAGE_KEY);
// window.currentUserThemePreference should be set in base.html if user is logged in
const serverUserPreference = window.currentUserThemePreference;
let effectiveTheme = 'system'; // Default
if (themeSelect || mobileThemeSelect) { // User is logged in and on a page with the theme selector
if (localPreference) {
effectiveTheme = localPreference;
} else if (serverUserPreference) {
effectiveTheme = serverUserPreference;
}
if (themeSelect) themeSelect.value = effectiveTheme;
if (mobileThemeSelect) mobileThemeSelect.value = effectiveTheme;
} else { // User is not logged in or on a page without selector (e.g. login page)
if (localPreference) {
effectiveTheme = localPreference;
}
// No serverUserPreference to check here
}
applyTheme(effectiveTheme);
if (effectiveTheme === 'system') {
setupSystemThemeListener();
} else {
removeSystemThemeListener(); // Ensure no listener if not 'system'
}
// For logged-in users, ensure localStorage is updated if server preference was used
if ((themeSelect || mobileThemeSelect) && serverUserPreference && !localPreference) {
storePreference(serverUserPreference);
}
function setupThemeChangeHandler(selector) {
if (selector) {
selector.addEventListener('change', function() {
const selectedTheme = this.value;
applyTheme(selectedTheme);
storePreference(selectedTheme);
savePreferenceToServer(selectedTheme); // Save to backend for logged-in user
// Sync both selectors
if (themeSelect && mobileThemeSelect) {
if (this === themeSelect) {
mobileThemeSelect.value = selectedTheme;
} else {
themeSelect.value = selectedTheme;
}
}
if (selectedTheme === 'system') {
setupSystemThemeListener();
} else {
removeSystemThemeListener();
}
});
}
}
setupThemeChangeHandler(themeSelect);
setupThemeChangeHandler(mobileThemeSelect);
handle2xAppImages();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTheme);
} else {
// DOMContentLoaded has already fired
initTheme();
}
})();

View File

@@ -1,336 +0,0 @@
"""Synchronization primitives for Tronbyt Server."""
import logging
import os
import ast
import base64
import redis
from abc import ABC, abstractmethod
from multiprocessing import current_process
from multiprocessing.synchronize import Event as MPEvent
from multiprocessing.synchronize import Lock as MPLock
from multiprocessing.managers import (
SyncManager,
DictProxy,
)
from multiprocessing.queues import Queue as ProcessQueue
from queue import Empty
from typing import Any, cast
from threading import Lock
from tronbyt_server.config import get_settings
from tronbyt_server.models.sync import SyncPayload
logger = logging.getLogger(__name__)
class Waiter(ABC):
"""Abstract base class for a waiter."""
@abstractmethod
def wait(self, timeout: int) -> SyncPayload | None:
"""Wait for a notification and return the payload."""
raise NotImplementedError
@abstractmethod
def close(self) -> None:
"""Clean up the waiter."""
raise NotImplementedError
class AbstractSyncManager(ABC):
"""Abstract base class for synchronization managers."""
@abstractmethod
def get_waiter(self, device_id: str) -> Waiter:
"""Get a waiter for a given device ID."""
raise NotImplementedError
@abstractmethod
def notify(self, device_id: str, payload: SyncPayload) -> None:
"""Notify waiters for a given device ID with a payload."""
raise NotImplementedError
@abstractmethod
def _shutdown(self) -> None:
"""Shut down the sync manager."""
raise NotImplementedError
@abstractmethod
def __enter__(self) -> "AbstractSyncManager":
"""Enter the context manager."""
raise NotImplementedError
@abstractmethod
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
"""Exit the context manager."""
raise NotImplementedError
class MultiprocessingWaiter(Waiter):
"""A waiter that uses multiprocessing primitives."""
def __init__(
self,
queue: "ProcessQueue[Any]",
manager: "MultiprocessingSyncManager",
device_id: str,
):
self._queue = queue
self._manager = manager
self._device_id = device_id
def wait(self, timeout: int) -> SyncPayload | None:
"""Wait for a notification."""
try:
return cast(SyncPayload, self._queue.get(timeout=timeout))
except Empty:
return None
def close(self) -> None:
"""Clean up the waiter."""
self._manager.release_queue(self._device_id)
class ServerSyncManager(SyncManager):
"""A custom SyncManager that creates and vends singleton sync primitives."""
_init_lock = Lock()
_initialized = False
_queues: DictProxy[Any, Any]
_waiter_counts: DictProxy[Any, Any]
_lock: MPLock
_shutdown_event: MPEvent
def _lazy_init(self) -> None:
"""
Initialize the shared primitives using double-checked locking to avoid
acquiring the lock on every call.
"""
if not self._initialized:
with self._init_lock:
if not self._initialized:
self._queues = self.dict()
self._waiter_counts = self.dict()
self._lock = cast(MPLock, self.Lock())
self._shutdown_event = cast(MPEvent, self.Event())
self._initialized = True
def get_queues(self) -> DictProxy[Any, Any]:
self._lazy_init()
return self._queues
def get_waiter_counts(self) -> DictProxy[Any, Any]:
self._lazy_init()
return self._waiter_counts
def get_lock(self) -> MPLock:
self._lazy_init()
return self._lock
def get_shutdown_event(self) -> MPEvent:
self._lazy_init()
return self._shutdown_event
def clear_all_queues(self) -> None:
"""Clear all queues to prevent new waiters."""
self._lazy_init()
with self._lock:
self._queues.clear()
self._waiter_counts.clear()
class MultiprocessingSyncManager(AbstractSyncManager):
"""A synchronization manager that uses multiprocessing primitives."""
_manager: "ServerSyncManager"
def __init__(self, address: Any = None, authkey: bytes | None = None) -> None:
if address:
# Client mode: connect to the server manager.
manager = ServerSyncManager(address=address, authkey=authkey)
manager.connect()
self._manager = manager
self._is_server = False
else:
# Server mode: create the manager that hosts the singletons.
manager = ServerSyncManager()
manager.start()
self._manager = manager
self._is_server = True
self._export_connection_details()
# Get proxies to the singleton objects.
self._queues = self._manager.get_queues()
self._waiter_counts = self._manager.get_waiter_counts()
self._lock = self._manager.get_lock()
self._shutdown_event = self._manager.get_shutdown_event()
def _export_connection_details(self) -> None:
"""Set environment variables for client processes to connect."""
if not self._is_server:
return
address = self.address
authkey = current_process().authkey
if address and authkey:
os.environ["TRONBYT_MP_MANAGER_ADDR"] = repr(address)
os.environ["TRONBYT_MP_MANAGER_AUTHKEY"] = base64.b64encode(authkey).decode(
"ascii"
)
@property
def address(self) -> Any:
"""Get the address of the manager process (server mode only)."""
if not self._is_server:
return None
return self._manager.address
def is_shutdown(self) -> bool:
return self._shutdown_event.is_set()
def release_queue(self, device_id: str) -> None:
"""Decrement waiter count and clean up queue if no waiters are left."""
with self._lock:
if device_id in self._waiter_counts:
self._waiter_counts[device_id] -= 1
if self._waiter_counts[device_id] == 0:
del self._queues[device_id]
del self._waiter_counts[device_id]
def get_waiter(self, device_id: str) -> Waiter:
"""Get a waiter for a given device ID."""
with self._lock:
if device_id not in self._queues:
self._queues[device_id] = self._manager.Queue()
self._waiter_counts[device_id] = 0
self._waiter_counts[device_id] += 1
queue = self._queues[device_id]
return MultiprocessingWaiter(queue, self, device_id)
def notify(self, device_id: str, payload: SyncPayload) -> None:
"""Notify waiters for a given device ID."""
with self._lock:
if device_id in self._queues:
self._queues[device_id].put(payload)
def _shutdown(self) -> None:
"""Shut down the sync manager."""
if self._is_server:
self._shutdown_event.set()
self._manager.clear_all_queues()
self._manager.shutdown()
def __enter__(self) -> "MultiprocessingSyncManager":
return self
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
self._shutdown()
class RedisWaiter(Waiter):
"""A waiter that uses Redis Pub/Sub."""
def __init__(self, redis_client: redis.Redis, device_id: str):
# The Redis client is synchronous
self._redis = redis_client
self._list_key = f"queue:{device_id}"
def wait(self, timeout: int) -> SyncPayload | None:
"""Wait for a notification."""
try:
# BLPOP returns a tuple (list_name, value) or None on timeout
result = cast(
tuple[bytes, bytes] | None,
self._redis.blpop([self._list_key], timeout=timeout),
)
if result:
_, payload_json = result
return SyncPayload.model_validate_json(payload_json)
except (ValueError, redis.ConnectionError) as e:
logger.error(f"Redis connection error in waiter: {e}")
return None
def close(self) -> None:
"""Clean up the waiter."""
pass
class RedisSyncManager(AbstractSyncManager):
"""A synchronization manager that uses Redis Pub/Sub."""
def __init__(self, redis_url: str) -> None:
self._redis: redis.Redis = redis.from_url(redis_url) # type: ignore
def get_waiter(self, device_id: str) -> Waiter:
"""Get a waiter for a given device ID."""
return RedisWaiter(self._redis, device_id)
def notify(self, device_id: str, payload: SyncPayload) -> None:
"""Notify waiters for a given device ID."""
list_key = f"queue:{device_id}"
try:
self._redis.rpush(list_key, payload.model_dump_json())
self._redis.expire(list_key, 60)
except redis.ConnectionError as e:
logger.error(f"Redis connection error in notify: {e}")
def _shutdown(self) -> None:
"""Shut down the sync manager."""
self._redis.close()
def __enter__(self) -> "RedisSyncManager":
return self
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
self._shutdown()
_sync_manager: AbstractSyncManager | None = None
_sync_manager_lock = Lock()
def get_sync_manager() -> AbstractSyncManager:
"""Get the synchronization manager for the application."""
global _sync_manager
if _sync_manager is None:
with _sync_manager_lock:
if _sync_manager is None: # Double-checked locking
settings = get_settings()
redis_url = settings.REDIS_URL
if redis_url:
logger.info("Using Redis for synchronization")
_sync_manager = RedisSyncManager(redis_url)
else:
# Check env vars for multiprocessing client mode
manager_address_str = os.environ.get("TRONBYT_MP_MANAGER_ADDR")
manager_authkey_b64 = os.environ.get("TRONBYT_MP_MANAGER_AUTHKEY")
if manager_address_str and manager_authkey_b64:
# Client mode for worker processes
logger.info("Connecting to parent sync manager...")
try:
address = ast.literal_eval(manager_address_str)
authkey = base64.b64decode(manager_authkey_b64)
_sync_manager = MultiprocessingSyncManager(
address=address, authkey=authkey
)
except Exception as e:
logger.error(
f"Failed to connect to parent sync manager: {e}"
)
raise
else:
# Server mode for the main process
logger.info(
"Using multiprocessing for synchronization (server)"
)
_sync_manager = MultiprocessingSyncManager()
assert _sync_manager is not None
return _sync_manager

View File

@@ -1,349 +0,0 @@
"""Utilities for managing the system apps repository."""
# clone system repo and generate the apps.json list. App description pulled from the YAML if available
import json
import logging
import os
import shutil
import yaml
from datetime import datetime
from pathlib import Path
from git import Git, GitCommandError, Repo
from tronbyt_server.config import get_settings
from tronbyt_server.models import AppMetadata
from tronbyt_server.git_utils import get_primary_remote, get_repo
logger = logging.getLogger(__name__)
def get_system_repo_info(base_path: Path) -> dict[str, str | None]:
"""Get information about the current system repo commit.
Returns:
dict with keys: 'commit_hash', 'commit_url', 'repo_url', 'branch', 'commit_message', 'commit_date'
"""
system_apps_path = base_path / "system-apps"
repo = get_repo(system_apps_path)
if not repo:
return {
"commit_hash": None,
"commit_url": None,
"repo_url": None,
"branch": None,
"commit_message": None,
"commit_date": None,
}
repo_web_url = None
branch_name = None
commit_hash = None
commit_url = None
commit_message = None
commit_date = None
try:
remote = get_primary_remote(repo)
if remote:
repo_url = remote.url
repo_web_url = repo_url.replace(".git", "")
except GitCommandError as e:
logger.warning(f"Could not get remote URL from {system_apps_path}: {e}")
try:
branch_name = repo.active_branch.name
except TypeError:
# Detached HEAD
branch_name = "DETACHED"
except GitCommandError as e:
logger.warning(f"Could not get branch name from {system_apps_path}: {e}")
try:
commit = repo.head.commit
commit_hash = commit.hexsha
# Handle both str and bytes from git commit message
msg = commit.message
if isinstance(msg, bytes):
msg = msg.decode("utf-8", errors="replace")
commit_message = msg.strip().split("\n")[0] # First line of commit message
commit_date = commit.committed_datetime.strftime("%Y-%m-%d %H:%M")
if repo_web_url:
commit_url = f"{repo_web_url}/tree/{commit_hash}"
except ValueError: # Handles empty repo
logger.warning(
f"Could not get commit hash from {system_apps_path}, repo may be empty."
)
except GitCommandError as e:
logger.warning(f"Could not get commit hash from {system_apps_path}: {e}")
return {
"commit_hash": commit_hash[:7] if commit_hash else None,
"commit_url": commit_url,
"repo_url": repo_web_url,
"branch": branch_name,
"commit_message": commit_message,
"commit_date": commit_date,
}
def generate_apps_json(base_path: Path) -> None:
"""Generate the system-apps.json file from the system-apps directory.
This function only processes the apps and generates the JSON file.
It does NOT do a git pull - use update_system_repo() for that.
"""
system_apps_path = base_path / "system-apps"
repo = get_repo(system_apps_path)
# find all the .star files in the apps_path directory
apps_array: list[AppMetadata] = []
apps = list(system_apps_path.rglob("*.star"))
apps.sort()
count = 0
skip_count = 0
new_previews = 0
num_previews = 0
new_previews_2x = 0
num_previews_2x = 0
static_images_path = base_path / "apps"
os.makedirs(static_images_path, exist_ok=True)
MAX_PREVIEW_SIZE_BYTES = 1 * 1024 * 1024 # 1MB
git_dates: dict[str, datetime] = {}
if repo:
try:
# Use git log with --name-only and --diff-filter to get last modification date for each file
# This processes all files in one command which is much faster
log_output = repo.git.log(
"--name-only",
"--pretty=format:%ci",
"--diff-filter=ACMR",
"--",
"*.star",
kill_after_timeout=30,
)
if log_output:
lines = log_output.strip().split("\n")
current_date = None
for line in lines:
line = line.strip()
if not line:
continue
try:
# Attempt to parse the line as a date. Format is "YYYY-MM-DD HH:MM:SS +/-ZZZZ"
git_date_str = line.split()[0:2]
current_date = datetime.strptime(
" ".join(git_date_str), "%Y-%m-%d %H:%M:%S"
)
except (ValueError, IndexError):
# If parsing fails, it's a file path.
if line.endswith(".star") and current_date:
filename = Path(line).name
# Only store the first (most recent) date we see for each file
if filename not in git_dates:
git_dates[filename] = current_date
logger.info(f"Retrieved git commit dates for {len(git_dates)} apps")
except GitCommandError as e:
logger.warning(f"Failed to get git dates in bulk: {e}")
for app in apps:
try:
app_basename = app.stem
# Use git date if available, otherwise fall back to file modification time
if app.name in git_dates:
mod_time: datetime = git_dates[app.name]
else:
mod_time = datetime.fromtimestamp(app.stat().st_mtime)
app_dict = AppMetadata(
name=app_basename,
fileName=app.name, # Store the actual filename with .star extension
path=str(app),
date=mod_time.strftime("%Y-%m-%d %H:%M"),
)
# Check if app uses secret.star module
app_str = app.read_text()
if "secret.star" in app_str:
logger.info(f"marking app {app.name} as broken (uses secret.star)")
app_dict.broken = True
app_dict.brokenReason = "Requires Secrets"
skip_count += 1
app_base_path = app.parent
yaml_path = app_base_path / "manifest.yaml"
# check for existence of yaml_path
if yaml_path.exists():
with yaml_path.open("r") as f:
yaml_dict = yaml.safe_load(f) or {}
# Merge YAML dict into Pydantic model
current_data = app_dict.model_dump(by_alias=True)
current_data.update(yaml_dict)
app_dict = AppMetadata.model_validate(current_data)
else:
app_dict.summary = "System App"
package_name = app_dict.package_name or app_base_path.name
# Check for a preview in the repo and copy it over to static previews directory
for image_name_base in [package_name, app_basename, "screenshot"]:
if app_dict.preview:
break
for ext in [".webp", ".gif", ".png"]:
image_name = f"{image_name_base}{ext}"
image_path = app_base_path / image_name
static_image_path = static_images_path / f"{app_basename}{ext}"
# less than a meg only
if (
image_path.exists()
and image_path.stat().st_size < MAX_PREVIEW_SIZE_BYTES
):
if not static_image_path.exists():
logger.info(
f"copying preview to static dir {static_image_path}"
)
new_previews += 1
shutil.copy(image_path, static_image_path)
# set the preview for the app to the static preview location
if static_image_path.exists():
if not app_dict.preview:
num_previews += 1
app_dict.preview = static_image_path.name
# Now check for a @2x version of this found preview
image_name_2x = f"{image_name_base}@2x{ext}"
image_path_2x = app_base_path / image_name_2x
static_image_path_2x = (
static_images_path / f"{app_basename}@2x{ext}"
)
if (
image_path_2x.exists()
and image_path_2x.stat().st_size
< MAX_PREVIEW_SIZE_BYTES
):
if not static_image_path_2x.exists():
logger.info(
"copying 2x preview to static dir"
f" {static_image_path_2x}"
)
new_previews_2x += 1
shutil.copy(image_path_2x, static_image_path_2x)
if static_image_path_2x.exists():
num_previews_2x += 1
app_dict.preview2x = static_image_path_2x.name
# Found preview and checked for 2x, break from ext loop
break
count += 1
apps_array.append(app_dict)
except Exception as e:
logger.info(f"skipped {app} due to error: {repr(e)}")
# writeout apps_array as a json file
logger.info(f"got {count} useable apps")
logger.info(f"skipped {skip_count} secrets.star using apps")
logger.info(f"copied {new_previews} new previews into static")
logger.info(f"total previews found: {num_previews}")
logger.info(f"copied {new_previews_2x} new 2x previews into static")
logger.info(f"total 2x previews found: {num_previews_2x}")
with (base_path / "system-apps.json").open("w") as f:
json.dump([a.model_dump(by_alias=True) for a in apps_array], f, indent=4)
def update_system_repo(base_path: Path) -> None:
"""Update the system apps repository and regenerate the apps.json file.
This function:
1. Does a git pull (or clone if needed)
2. Calls generate_apps_json() to process the apps
"""
system_apps_path = base_path / "system-apps"
# If running as root, add the system-apps directory to the safe.directory list.
# This must be done before any repo operations to prevent UnsafeRepositoryError.
if os.geteuid() == 0:
try:
g = Git()
# Check if the directory is already in the safe.directory list
try:
safe_dirs = g.config(
"--global", "--get-all", "safe.directory"
).splitlines()
except GitCommandError:
safe_dirs = []
if str(system_apps_path) not in safe_dirs:
logger.info(f"Adding {system_apps_path} to git safe.directory")
g.config("--global", "--add", "safe.directory", str(system_apps_path))
except GitCommandError as e:
logger.warning(f"Could not configure safe.directory: {e}")
repo = get_repo(system_apps_path)
if repo:
logger.info(f"{system_apps_path} git repo found, updating…")
try:
# Discard any local changes (they shouldn't exist)
if repo.is_dirty() or repo.untracked_files:
logger.info(
"Local changes detected in repository, resetting to clean state"
)
# Remove untracked files
repo.git.clean("-fd")
# Reset any modified files
repo.git.reset("--hard")
remote = get_primary_remote(repo)
if remote:
remote.pull(rebase=True)
logger.info("Repo updated")
else:
logger.warning(
f"No remote found to pull from for repo at {system_apps_path}"
)
except (GitCommandError, AttributeError, IndexError) as e:
logger.error(f"Error updating repository: {e}")
else:
system_apps_url = get_settings().SYSTEM_APPS_REPO
if "@" in system_apps_url and ".git@" in system_apps_url:
repo_url, branch = system_apps_url.rsplit("@", 1)
else:
repo_url = system_apps_url
branch = None
logger.info(f"Git repo not found in {system_apps_path}, cloning {repo_url}")
try:
if branch:
Repo.clone_from(
repo_url,
system_apps_path,
branch=branch,
single_branch=True,
depth=1,
)
else:
Repo.clone_from(repo_url, system_apps_path, depth=1)
logger.info("Repo Cloned")
except GitCommandError as e:
logger.error(f"Error Cloning Repo: {e}")
return # Exit early if clone failed
# Only generate apps.json if the repo exists
if system_apps_path.exists() and (system_apps_path / ".git").exists():
generate_apps_json(base_path)
else:
logger.error(
f"Cannot generate apps.json: {system_apps_path} does not contain a valid git repository"
)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("system_apps")
update_system_repo(Path(os.getcwd()))

View File

@@ -1,60 +0,0 @@
"""Jinja2 templates configuration."""
import datetime as dt
import time
from pathlib import Path
from babel.dates import format_timedelta
from babel.numbers import get_decimal_symbol
import jinja2
from fastapi.templating import Jinja2Templates
from fastapi_babel import _
from tronbyt_server.config import get_settings
from tronbyt_server.flash import get_flashed_messages
from tronbyt_server.dependencies import is_auto_login_active
def timeago(seconds: int, locale: str) -> str:
"""Format a timestamp as a time ago string."""
if seconds == 0:
return str(_("Never"))
return format_timedelta(
dt.timedelta(seconds=seconds - int(time.time())),
granularity="second",
add_direction=True,
locale=locale,
)
def duration(td: dt.timedelta, locale: str) -> str:
"""Format a timedelta object as a human-readable duration with millisecond precision."""
total_seconds = td.total_seconds()
decimal_symbol = get_decimal_symbol(locale)
if total_seconds < 60: # Less than 1 minute, show in seconds with 3 decimal places
return f"{total_seconds:.3f}".replace(".", decimal_symbol) + " s"
elif total_seconds < 3600: # Less than 1 hour, show minutes and seconds
minutes = int(total_seconds // 60)
seconds = total_seconds % 60
return f"{minutes} m {seconds:.0f} s"
else: # 1 hour or more, use babel's format_timedelta for larger units
return format_timedelta(
td,
granularity="second",
locale=locale,
)
env = jinja2.Environment(
loader=jinja2.FileSystemLoader(Path(__file__).parent.resolve() / "templates"),
autoescape=True,
)
env.globals["get_flashed_messages"] = get_flashed_messages
env.globals["_"] = _
env.globals["config"] = get_settings()
env.globals["is_auto_login_active"] = is_auto_login_active
env.filters["timeago"] = timeago
env.filters["duration"] = duration
templates = Jinja2Templates(env=env)

View File

@@ -1,244 +0,0 @@
{% extends 'base.html' %}
{% block header %}
{% endblock %}
{% block content %}
{% if user.username == "admin" %}
<h1>{{ _('System Administration') }}</h1>
<h2>{{ _('System Apps Repository') }}</h2>
<div style="border: 1px solid #333; border-radius: 4px; padding: 20px; margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
<div style="flex: 1;">
<strong>{{ _('Current Repository') }}</strong>
{% if system_repo_info and system_repo_info.commit_hash %}
<div style="margin-top: 8px;">
<div style="margin-bottom: 5px;">
<a href="{{ system_repo_info.repo_url }}" target="_blank" style="color: #4CAF50; text-decoration: none;">
{{ system_repo_info.repo_url }}
</a>
</div>
<div style="margin-bottom: 5px; color: #888; font-size: 0.9em;">
{{ _('Branch') }}: {{ system_repo_info.branch }}
</div>
<div style="color: #888; font-size: 0.9em;">
{{ _('Commit') }}:
<a href="{{ system_repo_info.commit_url }}" target="_blank" style="color: #4CAF50; text-decoration: none;" title="{{ system_repo_info.commit_hash }}">
{% if system_repo_info.commit_message %}
{{ system_repo_info.commit_message }}
{% else %}
{{ system_repo_info.commit_hash[:8] }}
{% endif %}
</a>
{% if system_repo_info.commit_date %}
<span style="margin-left: 8px; color: #666;">{{ system_repo_info.commit_date }}</span>
{% endif %}
</div>
</div>
{% else %}
<div style="margin-top: 8px; color: #888;">{{ _('No system repository configured.') }}</div>
{% endif %}
</div>
<form method="post" action="{{ url_for('refresh_system_repo') }}" style="margin-left: 15px;" onsubmit="var btn = this.querySelector('button'); btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '<i class=\'fa-solid fa-spinner fa-spin\'></i> {{ _('Refreshing...') }}';">
<button type="submit" class="w3-button w3-blue" style="padding: 8px 16px; font-size: 14px;">
<i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i> {{ _('Refresh') }}
</button>
</form>
</div>
<div style="border-top: 1px solid #333; padding-top: 15px;">
<strong>{{ _('Change Repository') }}</strong>
<form method="post" action="{{ url_for('set_system_repo') }}" style="margin-top: 10px;">
<div style="display: flex; gap: 10px; align-items: flex-end;">
<div style="flex: 1;">
<label for="app_repo_url" style="display: block; margin-bottom: 5px; font-size: 0.9em;">{{ _('Repository URL') }}</label>
<input name="app_repo_url" id="app_repo_url" required value="{{ user.system_repo_url }}" style="width: 100%; padding: 8px;">
</div>
<button type="submit" class="w3-button w3-green" style="padding: 8px 16px; font-size: 14px;">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ _('Save') }}
</button>
</div>
<p style="color: #888; font-size: 0.8em; margin-top: 8px;">{{ _('Replacing the existing system repo will delete the previous repo and break any apps using that repo.') }}</p>
</form>
</div>
</div>
<h2>{{ _('Firmware Management') }}</h2>
<div style="border: 1px solid #333; border-radius: 4px; padding: 20px; margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
<div style="flex: 1;">
<strong>{{ _('Current Firmware') }}</strong>
{% if firmware_version %}
<div style="margin-top: 8px;">
<div style="margin-bottom: 5px;">
<strong>{{ _('Version') }}:</strong> {{ firmware_version }}
</div>
<div style="color: #888; font-size: 0.9em;">
{{ _('Firmware is automatically updated when the server starts.') }}
</div>
</div>
{% else %}
<div style="margin-top: 8px; color: #888;">{{ _('No firmware version information available') }}</div>
{% endif %}
</div>
<form method="post" action="{{ url_for('update_firmware') }}" style="margin-left: 15px;" onsubmit="var btn = this.querySelector('button'); btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '<i class=\'fa-solid fa-spinner fa-spin\'></i> {{ _('Updating...') }}';">
<button type="submit" class="w3-button w3-blue" style="padding: 8px 16px; font-size: 14px;">
<i class="fa-solid {% if firmware_version %}fa-arrows-rotate{% else %}fa-download{% endif %}" aria-hidden="true"></i> {% if firmware_version %}{{ _('Update') }}{% else %}{{ _('Download') }}{% endif %}
</button>
</form>
</div>
<div style="border-top: 1px solid #333; padding-top: 15px;">
<p style="color: #888; font-size: 0.8em; margin: 0;">
<i class="fa-solid fa-circle-info" aria-hidden="true"></i> {{ _('Note: This updates the firmware files available for flashing, not the firmware on existing devices. Devices must be manually reflashed to use the new firmware.') }}
</p>
</div>
</div>
{% endif %}
<h1>{{ _('User Settings') }}</h1>
<h2>{{ _('Custom Apps Repository') }}</h2>
<div style="border: 1px solid #333; border-radius: 4px; padding: 20px; margin-bottom: 20px;">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
<div style="flex: 1;">
<strong>{{ _('Current Repository') }}</strong>
{% if user.app_repo_url %}
<div style="margin-top: 8px;">
<div style="margin-bottom: 5px;">
<a href="{{ user.app_repo_url }}" target="_blank" style="color: #4CAF50; text-decoration: none;">
{{ user.app_repo_url }}
</a>
</div>
<div style="color: #888; font-size: 0.9em;">
{{ _('Custom apps repository configured') }}
</div>
</div>
{% else %}
<div style="margin-top: 8px; color: #888;">{{ _('No custom repository configured.') }}</div>
{% endif %}
</div>
{% if user.app_repo_url %}
<div style="margin-left: 15px; display: flex; gap: 8px;">
<form method="post" action="{{ url_for('refresh_user_repo') }}" onsubmit="var btn = this.querySelector('button'); btn.disabled = true; btn.style.opacity = '0.6'; btn.innerHTML = '<i class=\'fa-solid fa-spinner fa-spin\'></i> {{ _('Refreshing...') }}';">
<button type="submit" class="w3-button w3-blue" style="padding: 8px 16px; font-size: 14px;">
<i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i> {{ _('Refresh') }}
</button>
</form>
<form method="post" action="{{ url_for('set_user_repo') }}" onsubmit="return confirm('{{ _('Remove custom repository? This will delete the repo and break any apps using it.') }}');">
<input type="hidden" name="app_repo_url" value="">
<button type="submit" class="w3-button w3-red" style="padding: 8px 16px; font-size: 14px;">
<i class="fa-solid fa-trash" aria-hidden="true"></i> {{ _('Remove') }}
</button>
</form>
</div>
{% endif %}
</div>
<div style="border-top: 1px solid #333; padding-top: 15px;">
<strong>{{ _('Change Repository') }}</strong>
<form method="post" action="{{ url_for('set_user_repo') }}" style="margin-top: 10px;">
<div style="display: flex; gap: 10px; align-items: flex-end;">
<div style="flex: 1;">
<label for="app_repo_url" style="display: block; margin-bottom: 5px; font-size: 0.9em;">{{ _('Repository URL') }}</label>
<input name="app_repo_url" id="app_repo_url" required value="{{ user.app_repo_url }}" style="width: 100%; padding: 8px;">
</div>
<button type="submit" class="w3-button w3-green" style="padding: 8px 16px; font-size: 14px;">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ _('Save') }}
</button>
</div>
<p style="color: #888; font-size: 0.8em; margin-top: 8px;">{{ _('Replacing an existing custom repo will delete the previous repo and break any apps using that repo.') }}</p>
</form>
</div>
</div>
<h2>{{ _('Security Settings') }}</h2>
<div style="border: 1px solid #333; border-radius: 4px; padding: 20px; margin-bottom: 20px;">
<div style="margin-bottom: 20px;">
<strong>{{ _('Change Password') }}</strong>
<form method="post" style="margin-top: 10px;">
<div style="display: flex; gap: 10px; align-items: flex-end;">
<div style="flex: 1;">
<label for="old_password" style="display: block; margin-bottom: 5px; font-size: 0.9em;">{{ _('Old Password') }}</label>
<input type="password" name="old_password" id="old_password" required style="width: 100%; padding: 8px;">
</div>
<div style="flex: 1;">
<label for="password" style="display: block; margin-bottom: 5px; font-size: 0.9em;">{{ _('New Password') }}</label>
<input type="password" name="password" id="password" required style="width: 100%; padding: 8px;">
</div>
<button type="submit" class="w3-button w3-green" style="padding: 8px 16px; font-size: 14px;">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ _('Save') }}
</button>
</div>
</form>
</div>
<div style="border-top: 1px solid #333; padding-top: 15px;">
<form method="post" action="{{ url_for('set_api_key') }}" style="display: flex; gap: 10px; align-items: flex-end;">
<div style="flex: 1;">
<label for="api_key" style="display: block; margin-bottom: 5px; font-size: 0.9em;">{{ _('User API Key') }}</label>
<input type="text" name="api_key" id="api_key" required value="{{ user.api_key or '' }}" style="width: 100%; padding: 8px;">
</div>
<button type="submit" class="w3-button w3-green" style="padding: 8px 16px; font-size: 14px;" onclick="return confirm('{{ _('Update API key?') }}');">
<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ _('Save') }}
</button>
</form>
<form method="post" action="{{ url_for('generate_api_key') }}" style="margin-top: 10px;">
<button type="submit" class="w3-button w3-blue" style="padding: 8px 16px; font-size: 14px;" onclick="return confirm('{{ _('Generate a new API key? This will replace your current API key.') }}');">
<i class="fa-solid fa-arrows-rotate" aria-hidden="true"></i> {{ _('Generate New') }}
</button>
</form>
</div>
</div>
<h2>{{ _('Data Management') }}</h2>
<div style="border: 1px solid #333; border-radius: 4px; padding: 20px; margin-bottom: 20px;">
<p style="color: #888; font-size: 0.9em; margin-bottom: 20px;">{{ _('Use the export and import features to backup your user account or to move your devices to a new server. The export function creates a JSON file with all your settings, devices, and apps. You can then import this file on another server to restore your configuration. Note that your password is not included in the export for security reasons, so you will need to remember your password when importing to a new server.') }}</p>
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
<div style="flex: 1;">
<strong>{{ _('Export Configuration') }}</strong>
<div style="margin-top: 8px; color: #888; font-size: 0.9em;">
{{ _('Download your entire user configuration as a JSON file.') }}
</div>
</div>
<a href="{{ url_for('export_user_config') }}" class="w3-button w3-blue" style="padding: 8px 16px; font-size: 14px; margin-left: 15px;">
<i class="fa-solid fa-download" aria-hidden="true"></i> {{ _('Export') }}
</a>
</div>
<div style="border-top: 1px solid #333; padding-top: 15px;">
<strong>{{ _('Import Configuration') }}</strong>
<div style="margin-top: 8px; color: #888; font-size: 0.9em; margin-bottom: 10px;">
{{ _('Import your user configuration from a JSON file. This will replace your current configuration, but preserve your username and password.') }}
</div>
<form method="post" action="{{ url_for('import_user_config') }}" enctype="multipart/form-data">
<div style="display: flex; gap: 10px; align-items: flex-end;">
<div style="flex: 1;">
<label for="file" style="display: block; margin-bottom: 5px; font-size: 0.9em;">{{ _('Select JSON File') }}</label>
<input type="file" name="file" id="file" accept=".json" required style="width: 100%; padding: 8px;">
</div>
<button type="submit" class="w3-button w3-blue" style="padding: 8px 16px; font-size: 14px;">
<i class="fa-solid fa-upload" aria-hidden="true"></i> {{ _('Import') }}
</button>
</div>
</form>
</div>
</div>
<hr style="margin-top: 40px; border: none; border-top: 1px solid #333;">
{% if update_available %}
<div style="text-align: center; margin-top: 20px;">
<a href="{{ latest_release_url }}" target="_blank" style="color: #ff5722; text-decoration: none; font-weight: bold;">
<i class="fa-solid fa-circle-arrow-up"></i> {{ _('Update available!') }}
</a>
</div>
{% endif %}
<div style="font-size: 12px; color: #888; text-align: center; margin-top: {% if update_available %}5px{% else %}20px{% endif %};">
{{ _('Server') }} {{ server_version_info.version }}{% if server_version_info.commit_hash %} (<a href="https://github.com/tronbyt/tronbyt-server/tree/{{ server_version_info.commit_hash }}" target="_blank" style="color: #4CAF50; text-decoration: none; font-family: monospace;">{{ server_version_info.commit_hash[:7] }}</a>){% endif %}
</div>
{% endblock %}

View File

@@ -1,17 +0,0 @@
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}{{ _('Log In') }}{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="post">
<label for="username">{{ _('Username') }}</label>
<input name="username" id="username" required>
<label for="password">{{ _('Password') }}</label>
<input type="password" name="password" id="password" required>
<div>
<input type="checkbox" name="remember" id="remember">
<label for="remember">{{ _('Remember Me') }}</label>
</div>
<button type="submit" class="w3-button w3-green"><i class="fa-solid fa-right-to-bracket" aria-hidden="true"></i> {{ _('Log In') }}</button>
</form>
{% endblock %}

View File

@@ -1,16 +0,0 @@
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}{{ _('Register') }}{% endblock %}</h1>
{% endblock %}
{% block content %}
<form method="POST">
<label for="username">{{ _('Username (alpha-numeric only, no symbols please.)') }}</label>
<input name="username" id="username" required>
<label for="password">{{ _('Password') }}</label>
<input type="password" name="password" id="password" required>
<label for="email">{{ _('Email Address (optional)') }}</label>
<input type="text" name="email" id="email">
<button type="submit" class="w3-button w3-green"><i class="fa-solid fa-user-plus" aria-hidden="true"></i> {{ _('Register') }}</button>
</form>
{% endblock %}

View File

@@ -1,25 +0,0 @@
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}{{ _('Create Admin User') }}{% endblock %}</h1>
{% endblock %}
{% block content %}
<!-- Auto-Login Mode Warning Banner -->
{% if config.SINGLE_USER_AUTO_LOGIN == '1' %}
<div class="auto-login-warning-banner">
<div class="auto-login-warning-content">
<span class="auto-login-warning-icon">⚠️</span>
<strong>{{ _('SINGLE-USER AUTO-LOGIN MODE ACTIVE') }}</strong>
<span class="auto-login-warning-icon">⚠️</span>
</div>
<div class="auto-login-warning-subtext">
{{ _('Authentication is disabled for private network access') }}
</div>
</div>
{% endif %}
<form method="post">
<label for="password">{{ _('Password') }}</label>
<input type="password" name="password" id="password" required>
<button type="submit" class="w3-button w3-green"><i class="fa-solid fa-user-plus" aria-hidden="true"></i> {{ _('Create') }}</button>
</form>
{% endblock %}

View File

@@ -1,232 +0,0 @@
<!doctype html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
// Inline script to set initial theme and prevent FOUC
(function () {
var storedTheme = localStorage.getItem('theme_preference');
// Get server preference if user is logged in, otherwise default to 'system'
var serverProvidedTheme = "{% if user %}{{ user.theme_preference.value }}{% else %}system{% endif %}";
var themeToApply = storedTheme || serverProvidedTheme;
if (themeToApply === 'system') {
var systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.setAttribute('data-theme', systemPrefersDark ? 'dark' : 'light');
} else {
document.documentElement.setAttribute('data-theme', themeToApply);
}
// The full theme.js script will later synchronize the select dropdown and localStorage if needed.
})();
</script>
</head>
<title>{% if self.title() %}{% block title %}{% endblock %} - {% endif %}{{ _('Tronbyt Manager') }}</title>
<link rel="icon" href="{{ url_for('static', path='favicon.ico') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/w3.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/base.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/common.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/partials.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/fontawesome.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/brands.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='css/solid.min.css') }}">
<nav>
<button class="hamburger" id="hamburgerButton">
<span></span>
<span></span>
<span></span>
</button>
<h1 class="w3-sans-serif"><a href="{{ url_for('index') }}" style="text-decoration: none; color: inherit;">{{ _('Tronbyt Manager') }}</a></h1>
<ul>
{% if user %}
<li><a href="{{ url_for('index') }}">{{ _('Home') }}</a></li>
{% if user.username == 'admin' %}
<li>
<a href="{{ url_for('register') }}" {% if config.SINGLE_USER_AUTO_LOGIN == '1' %}onclick="return confirm('{{ _('⚠️ WARNING: Adding a second user will DISABLE auto-login functionality.') }}\n\n{{ _('Auto-login only works when exactly 1 user exists. After creating this user, you will need to log in with a password.') }}\n\n{{ _('Do you want to continue?') }}');"{% endif %}>
{{ _('Create User') }}
</a>
</li>
{% endif %}
<li><a href="{{ url_for('edit') }}">{{ user.username }}</a></li>
<li>
<a href="{{ url_for('logout') }}" {% if is_auto_login_active() %}onclick="event.preventDefault(); alert('{{ _('Auto-login mode is enabled. You cannot log out while this mode is active. To disable auto-login, set SINGLE_USER_AUTO_LOGIN=0 in your .env file or create a second user.') }}'); return false;"{% endif %}>
{% if is_auto_login_active() %}
<span class="auto-login-indicator" title="{{ _('Auto-login mode active') }}">⚠️</span>
{% endif %}
{{ _('Log Out') }}
</a>
</li>
{% if user %}
<li class="theme-toggle-container">
<div class="theme-toggle">
<label for="theme-select">{{ _('Theme:') }}</label>
<select id="theme-select" name="theme">
<option value="light">{{ _('Light') }}</option>
<option value="dark">{{ _('Dark') }}</option>
<option value="system" selected>{{ _('System') }}</option> {# Default to system selected #}
</select>
</div>
</li>
{% endif %}
{% else %}
<li><a href="{{ url_for('login') }}">{{ _('Log In') }}</a></li>
{% if config.ENABLE_USER_REGISTRATION == '1' %}
<li><a href="{{ url_for('register') }}">{{ _('Create User') }}</a></li>
{% endif %}
{% endif %}
</ul>
</nav>
<!-- Mobile Menu Overlay -->
<div class="mobile-menu-overlay" id="mobileMenuOverlay"></div>
<!-- Mobile Menu -->
<div class="mobile-menu" id="mobileMenu">
<div class="mobile-menu-header">
<h1><a href="{{ url_for('index') }}" onclick="closeMobileMenu()" style="text-decoration: none; color: inherit;">{{ _('Tronbyt Manager') }}</a></h1>
<button class="mobile-menu-close" id="mobileMenuClose">&times;</button>
</div>
<ul>
{% if user %}
<li><a href="{{ url_for('index') }}" onclick="closeMobileMenu()">{{ _('Home') }}</a></li>
{% if user.username == 'admin' %}
<li>
<a href="{{ url_for('register') }}" onclick="{% if config.SINGLE_USER_AUTO_LOGIN == '1' %}if (!confirm('{{ _('⚠️ WARNING: Adding a second user will DISABLE auto-login functionality.') }}\n\n{{ _('Auto-login only works when exactly 1 user exists. After creating this user, you will need to log in with a password.') }}\n\n{{ _('Do you want to continue?') }}')) { return false; } {% endif %}closeMobileMenu()">
{{ _('Create User') }}
</a>
</li>
{% endif %}
<li><a href="{{ url_for('edit') }}" onclick="closeMobileMenu()">{{ user.username }}</a></li>
<li>
<a href="{{ url_for('logout') }}" onclick="{% if is_auto_login_active() %}event.preventDefault(); alert('{{ _('Auto-login mode is enabled. You cannot log out while this mode is active. To disable auto-login, set SINGLE_USER_AUTO_LOGIN=0 in your .env file or create a second user.') }}'); return false;{% else %}closeMobileMenu(){% endif %}">
{% if is_auto_login_active() %}
<span class="auto-login-indicator" title="{{ _('Auto-login mode active') }}">⚠️</span>
{% endif %}
{{ _('Log Out') }}
</a>
</li>
{% if user %}
<li class="theme-toggle-container">
<div class="theme-toggle">
<label for="mobile-theme-select">{{ _('Theme:') }}</label>
<select id="mobile-theme-select" name="theme">
<option value="light">{{ _('Light') }}</option>
<option value="dark">{{ _('Dark') }}</option>
<option value="system" selected>{{ _('System') }}</option>
</select>
</div>
</li>
{% endif %}
{% else %}
<li><a href="{{ url_for('login') }}" onclick="closeMobileMenu()">{{ _('Log In') }}</a></li>
{% if config.ENABLE_USER_REGISTRATION == '1' %}
<li><a href="{{ url_for('register') }}" onclick="closeMobileMenu()">{{ _('Create User') }}</a></li>
{% endif %}
{% endif %}
</ul>
</div>
<section class="content">
<header>
{% block header %}{% endblock %}
</header>
{% with messages = get_flashed_messages(request) %}
{% if messages %}
<div class="flash">
{% for message in messages %}
<div>{{ message.message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</section>
<script>
{% if user %}
window.currentUserThemePreference = "{{ user.theme_preference.value }}";
{% else %}
window.currentUserThemePreference = "system"; // Default for guests or if not set
{% endif %}
// Mobile menu state management
let isMenuOpen = false;
function openMobileMenu() {
if (!isMenuOpen) {
const mobileMenu = document.getElementById('mobileMenu');
const mobileMenuOverlay = document.getElementById('mobileMenuOverlay');
const hamburger = document.querySelector('.hamburger');
mobileMenu.classList.add('active');
mobileMenuOverlay.classList.add('active');
hamburger.classList.add('active');
document.body.style.overflow = 'hidden';
isMenuOpen = true;
}
}
function closeMobileMenu() {
if (isMenuOpen) {
const mobileMenu = document.getElementById('mobileMenu');
const mobileMenuOverlay = document.getElementById('mobileMenuOverlay');
const hamburger = document.querySelector('.hamburger');
mobileMenu.classList.remove('active');
mobileMenuOverlay.classList.remove('active');
hamburger.classList.remove('active');
document.body.style.overflow = '';
isMenuOpen = false;
}
}
function toggleMobileMenu() {
if (isMenuOpen) {
closeMobileMenu();
} else {
openMobileMenu();
}
}
// Initialize mobile menu
document.addEventListener('DOMContentLoaded', function() {
const hamburgerButton = document.getElementById('hamburgerButton');
const mobileMenuClose = document.getElementById('mobileMenuClose');
const mobileMenuOverlay = document.getElementById('mobileMenuOverlay');
// Hamburger button
if (hamburgerButton) {
hamburgerButton.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
toggleMobileMenu();
});
}
// Close button
if (mobileMenuClose) {
mobileMenuClose.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
closeMobileMenu();
});
}
// Overlay click to close
if (mobileMenuOverlay) {
mobileMenuOverlay.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
closeMobileMenu();
});
}
// Escape key to close
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && isMenuOpen) {
closeMobileMenu();
}
});
});
</script>
<script src="{{ url_for('static', path='js/theme.js') }}" defer></script>

View File

@@ -1,731 +0,0 @@
{% extends 'base.html' %}
{% block header %}
<link rel="stylesheet" href="{{ url_for('static', path='css/addapp-simple.css') }}">
<h1>{% block title %}{{ _('Add App') }}{% endblock %}</h1>
{% endblock %}
{% block content %}
<!-- Loading indicator -->
<div id="loading-indicator" class="loading-container">
<div class="loading-spinner"></div>
<p>{{ _('Loading apps...') }}</p>
</div>
<script>
// Performance optimizations
let isInitialLoad = true;
let appItems = [];
let filteredItems = [];
let sortType = 'system';
let searchFilter = '';
let hideInstalled = false;
let hideBroken = false;
let isProcessing = false;
// Debounce function to limit function calls
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Optimized search with debouncing
const debouncedSearch = debounce((searchId) => {
applyFilters();
}, 100);
function searchApps(searchId, gridId) {
debouncedSearch(searchId);
}
function toggleInstalledApps(searchId) {
hideInstalled = document.getElementById('hide_installed_' + searchId).checked;
applyFilters();
}
function toggleBrokenApps(searchId) {
hideBroken = document.getElementById('hide_broken_' + searchId).checked;
applyFilters();
}
function sortApps(searchId) {
sortType = document.getElementById('sort_' + searchId).value;
applyFilters();
}
// Optimized sorting function
function sortItems(items, sortTypeParam = null) {
const currentSortType = sortTypeParam || sortType;
// Parse date function for newest sort
const parseDate = (dateStr) => {
if (!dateStr) return new Date(0);
// Handle format "YYYY-MM-DD HH:MM"
const parts = dateStr.split(' ');
if (parts.length === 2) {
const [datePart, timePart] = parts;
const [year, month, day] = datePart.split('-').map(Number);
const [hour, minute] = timePart.split(':').map(Number);
return new Date(year, month - 1, day, hour, minute);
}
return new Date(dateStr);
};
// Create a copy to avoid mutating the original array
const itemsCopy = [...items];
const sortedItems = itemsCopy.sort((a, b) => {
const nameA = a.getAttribute('data-name');
const nameB = b.getAttribute('data-name');
const installedA = a.getAttribute('data-installed') === 'true';
const installedB = b.getAttribute('data-installed') === 'true';
const dateA = a.getAttribute('data-date') || '';
const dateB = b.getAttribute('data-date') || '';
switch (currentSortType) {
case 'alphabetical':
return nameA.localeCompare(nameB);
case 'rev-alphabetical':
return nameB.localeCompare(nameA);
case 'newest':
// Convert date strings to Date objects for proper chronological sorting
const dateA_obj = parseDate(dateA);
const dateB_obj = parseDate(dateB);
const dateComparison = dateB_obj.getTime() - dateA_obj.getTime();
return dateComparison === 0 ? nameA.localeCompare(nameB) : dateComparison;
case 'system':
default:
if (installedA && !installedB) return -1;
if (!installedA && installedB) return 1;
return nameA.localeCompare(nameB);
}
});
return sortedItems;
}
// Function to show all items in a grid
function showAllItems(grid) {
const allItems = Array.from(grid.getElementsByClassName('app-item'));
console.log(`Showing all ${allItems.length} items in grid ${grid.id}`);
allItems.forEach(item => {
item.style.display = 'block';
});
}
// Robust filtering system that handles all combinations
function applyFilters() {
if (isInitialLoad || isProcessing) return;
isProcessing = true;
// Use requestAnimationFrame to batch DOM updates and prevent UI blocking
requestAnimationFrame(() => {
try {
const grids = document.querySelectorAll('.app-grid');
grids.forEach(grid => {
if (!grid) return;
const searchId = grid.id.replace('_app_grid', '_search');
// Get current filter values
const searchInput = document.getElementById(searchId);
const currentSearchFilter = searchInput ? searchInput.value.toLowerCase().trim() : '';
const currentHideInstalled = document.getElementById('hide_installed_' + searchId)?.checked || false;
const currentHideBroken = document.getElementById('hide_broken_' + searchId)?.checked || false;
const currentSortType = document.getElementById('sort_' + searchId)?.value || 'system';
// Get all app items from the grid
const allItems = Array.from(grid.getElementsByClassName('app-item'));
// Filter items based on all criteria
const filteredItems = allItems.filter(item => {
const isInstalled = item.getAttribute('data-installed') === 'true';
const isBroken = item.getAttribute('data-broken') === 'true';
const name = item.getAttribute('data-name').toLowerCase();
const author = item.getAttribute('data-author' || '').toLowerCase();
const summary = item.querySelector('p')?.textContent?.toLowerCase() || '';
// Apply search filter (search name, summary, and author if search begins with @)
if (currentSearchFilter) {
if (currentSearchFilter.startsWith("@")) {
if (!author.includes(currentSearchFilter.substring(1))) {
return false;
}
} else {
if (!name.includes(currentSearchFilter) && !summary.includes(currentSearchFilter)) {
return false;
}
}
}
// Apply hide filters
if (currentHideInstalled && isInstalled) return false;
if (currentHideBroken && isBroken) return false;
return true;
});
// Sort filtered items
const sortedItems = sortItems(filteredItems, currentSortType);
// Use requestAnimationFrame to prevent UI blocking during DOM reordering
requestAnimationFrame(() => {
// Batch DOM operations for better performance
const fragment = document.createDocumentFragment();
// Add sorted items to fragment
sortedItems.forEach((item, index) => {
fragment.appendChild(item);
item.style.display = 'block';
});
// Append all at once to reduce reflows
grid.appendChild(fragment);
// Hide any remaining items that weren't in the filtered list
allItems.forEach(item => {
if (!sortedItems.includes(item)) {
item.style.display = 'none';
}
});
});
// Update virtual scrolling state
updateVirtualScrolling(grid, sortedItems, currentSearchFilter);
// Update installed class states
updateItemStates(allItems);
});
} finally {
isProcessing = false;
}
});
}
// Update virtual scrolling based on current state
function updateVirtualScrolling(grid, sortedItems, hasSearchFilter) {
if (!grid._virtualScrolling) return;
const ITEMS_PER_PAGE = grid._virtualScrolling.ITEMS_PER_PAGE;
const shouldUseVirtualScrolling = sortedItems.length > ITEMS_PER_PAGE && !hasSearchFilter;
if (shouldUseVirtualScrolling) {
// Hide items beyond the first page
sortedItems.forEach((item, index) => {
if (index >= ITEMS_PER_PAGE) {
item.style.display = 'none';
}
});
// Update virtual scrolling state
grid._virtualScrolling.visibleEnd = Math.min(ITEMS_PER_PAGE, sortedItems.length);
grid._virtualScrolling.isLoading = false;
grid._virtualScrolling.items = sortedItems;
} else {
// Show all items
grid._virtualScrolling.visibleEnd = sortedItems.length;
grid._virtualScrolling.isLoading = false;
grid._virtualScrolling.items = sortedItems;
}
}
// Update item states (installed class, etc.)
function updateItemStates(allItems) {
allItems.forEach(item => {
if (!item) return;
const isInstalled = item.getAttribute('data-installed') === 'true';
if (isInstalled) {
item.classList.add('installed');
} else {
item.classList.remove('installed');
}
});
}
// Intersection Observer for lazy loading images
function setupLazyLoading() {
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute('data-src');
// Add loaded class when image loads
img.onload = function() {
img.classList.add('loaded');
};
// Also add loaded class immediately for cached images
if (img.complete) {
img.classList.add('loaded');
}
observer.unobserve(img);
} else {
console.warn('Image source is empty or undefined.');
}
}
});
}, {
rootMargin: '50px 0px',
threshold: 0.1
});
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
}
// Virtual scrolling for large lists (optional optimization)
function setupVirtualScrolling() {
const grids = document.querySelectorAll('.app-grid');
const ITEMS_PER_PAGE = 50; // Load 50 items at a time
grids.forEach(grid => {
if (!grid) return;
const items = Array.from(grid.getElementsByClassName('app-item'));
if (!items || items.length <= ITEMS_PER_PAGE) return; // Skip if list is small
let visibleStart = 0;
let visibleEnd = ITEMS_PER_PAGE;
let isLoading = false;
// Store reference to this grid's virtual scrolling state
grid._virtualScrolling = {
visibleStart,
visibleEnd,
isLoading,
items,
ITEMS_PER_PAGE
};
// Initially hide items beyond the first page only for system sort and large lists
if (items && items.length > ITEMS_PER_PAGE) {
// Check if we should apply virtual scrolling initially
const shouldApplyVirtualScrolling = sortType === 'system' ||
(document.getElementById('system_search') && !document.getElementById('system_search').value.trim());
if (shouldApplyVirtualScrolling) {
items.forEach((item, index) => {
if (!item) return;
if (index >= ITEMS_PER_PAGE) {
item.style.display = 'none';
}
});
}
}
// Function to load more items
function loadMoreItems() {
if (isLoading || visibleEnd >= items.length) return;
isLoading = true;
const nextStart = visibleEnd;
const nextEnd = Math.min(visibleEnd + ITEMS_PER_PAGE, items.length);
// Use requestAnimationFrame to batch DOM updates and prevent UI blocking
requestAnimationFrame(() => {
for (let i = nextStart; i < nextEnd; i++) {
if (items[i]) {
items[i].style.display = 'block';
}
}
visibleEnd = nextEnd;
isLoading = false;
// Update the stored state
grid._virtualScrolling.visibleEnd = visibleEnd;
grid._virtualScrolling.isLoading = isLoading;
// Re-observe the new last item
updateObserver();
});
}
// Function to load more items from filtered results
function loadMoreFilteredItems() {
if (!grid._virtualScrolling || grid._virtualScrolling.isLoading) return;
const filteredItems = grid._virtualScrolling.items;
const currentVisibleEnd = grid._virtualScrolling.visibleEnd;
const ITEMS_PER_PAGE = grid._virtualScrolling.ITEMS_PER_PAGE;
if (currentVisibleEnd >= filteredItems.length) return;
grid._virtualScrolling.isLoading = true;
const nextStart = currentVisibleEnd;
const nextEnd = Math.min(currentVisibleEnd + ITEMS_PER_PAGE, filteredItems.length);
// Use requestAnimationFrame to batch DOM updates and prevent UI blocking
requestAnimationFrame(() => {
for (let i = nextStart; i < nextEnd; i++) {
if (filteredItems[i]) {
filteredItems[i].style.display = 'block';
}
}
grid._virtualScrolling.visibleEnd = nextEnd;
grid._virtualScrolling.isLoading = false;
// Re-observe the new last item
updateObserver();
});
}
// Function to update the observer
function updateObserver() {
if (visibleEnd >= items.length) {
paginationObserver.disconnect();
return;
}
// Observe the last few visible items to ensure we catch scroll events
const observeStart = Math.max(0, visibleEnd - 3);
const observeEnd = Math.min(visibleEnd, items.length);
for (let i = observeStart; i < observeEnd; i++) {
if (items[i] && items[i].style.display !== 'none') {
paginationObserver.observe(items[i]);
}
}
}
// Set up intersection observer for pagination
const paginationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Check if we have filtered items (filters are active)
if (grid._virtualScrolling && grid._virtualScrolling.items && grid._virtualScrolling.items.length > 0) {
loadMoreFilteredItems();
} else {
loadMoreItems();
}
}
});
}, {
rootMargin: '200px 0px' // Increased margin to trigger earlier
});
// Also observe the grid container for scroll events
const gridObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Check if we're near the bottom and need to load more
const rect = entry.boundingClientRect;
const viewportHeight = window.innerHeight;
// If the grid is visible and we're near the bottom, load more
if (rect.bottom < viewportHeight + 300) {
// Check if we have filtered items (filters are active)
if (grid._virtualScrolling && grid._virtualScrolling.items && grid._virtualScrolling.items.length > 0) {
loadMoreFilteredItems();
} else {
loadMoreItems();
}
}
}
});
}, {
rootMargin: '300px 0px'
});
// Initial setup
updateObserver();
gridObserver.observe(grid);
// Also listen for scroll events as a fallback
let scrollTimeout;
window.addEventListener('scroll', () => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const rect = grid.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// If grid is visible and we're near the bottom, load more
if (rect.top < viewportHeight && rect.bottom > 0 && rect.bottom < viewportHeight + 500) {
// Check if we have filtered items (filters are active)
if (grid._virtualScrolling && grid._virtualScrolling.items && grid._virtualScrolling.items.length > 0) {
loadMoreFilteredItems();
} else {
loadMoreItems();
}
}
}, 100);
});
});
}
</script>
<a class="w3-button w3-purple w3-round w3-padding" href="{{ url_for('uploadapp', device_id=device.id) }}"><i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i> {{ _('Upload .star or .webp file') }}</a>
<form method="post" id="main_form">
<label for="name">{{ _('App Selection') }}</label>
{% macro render_app_list(title, search_id, grid_id, app_list, show_controls=true, show_version=false, is_custom_apps=false) %}
<div class="app-group">
<h3 style="display: inline-block; margin-right: 10px;">{{ title }}</h3>
{% if show_version and system_repo_info and system_repo_info.commit_hash %}
<span style="font-size: 14px; color: #888;">
(version: <a href="{{ system_repo_info.commit_url }}" target="_blank" style="color: #4CAF50; text-decoration: none;" title="{{ system_repo_info.commit_hash }}">
{% if system_repo_info.commit_message %}{{ system_repo_info.commit_message }}{% else %}{{ system_repo_info.commit_hash }}{% endif %}
</a>{% if system_repo_info.commit_date %} - {{ system_repo_info.commit_date }}{% endif %})
</span>
{% endif %}
{% if show_controls %}
<!-- Filter and Sort Controls -->
<div class="filter-sort-controls">
<div class="filter-sort-controls-inner">
<input type="search" id="{{ search_id }}" placeholder="{{ _('Search apps') }}"
onkeyup="searchApps('{{ search_id }}', '{{ grid_id }}')"
oninput="searchApps('{{ search_id }}', '{{ grid_id }}')" />
<label class="control-label">
<input type="checkbox" id="hide_installed_{{ search_id }}" onchange="toggleInstalledApps('{{ search_id }}')"
class="control-checkbox">
{{ _('Hide installed apps') }}
</label>
<label class="control-label">
<input type="checkbox" id="hide_broken_{{ search_id }}" onchange="toggleBrokenApps('{{ search_id }}')"
class="control-checkbox" checked>
{{ _('Hide broken apps') }}
</label>
<label for="sort_{{ search_id }}" class="control-label">
{{ _('Sort by:') }}
<select id="sort_{{ search_id }}" onchange="sortApps('{{ search_id }}')">
<option value="system" selected>{{ _('Default') }}</option>
<option value="newest">{{ _('Newest') }}</option>
<option value="alphabetical">{{ _('Alphabetical (A-Z)') }}</option>
<option value="rev-alphabetical">{{ _('Alphabetical (Z-A)') }}</option>
</select>
</label>
</div>
</div>
{% endif %}
<div id="{{ grid_id }}" class="app-grid">
{% for app in app_list %}
<div class="app-item {% if app.broken %}broken-app{% endif %}" data-value="{{ app.name }}" data-installed="{{ app.is_installed | lower }}"
data-date="{{ app.date }}" data-name="{{ app.name }}" data-broken="{{ app.broken | lower }}" data-author="{{ app.author }}" data-recommended-interval="{{ app.recommended_interval }}">
{% if app.is_installed %}
<div class="installed-badge">{{ _('Installed') }}</div>
{% endif %}
{% if app.supports2x %}
<div class="supports-2x-badge">2x</div>
{% endif %}
<!-- Skeleton loader for images -->
<div class="app-img">
<div class="skeleton-loader"></div>
{% if app.preview or app.preview2x %}
<img data-src="{{ url_for('app_preview', filename=app.preview2x or app.preview) }}"
alt="{{ app.name }}"
class="lazy-image"
{% if app.broken %}style="opacity: 0.4;"{% endif %}>
{% else %}
<img data-src="{{ url_for('static', path='images/not_found.jpg') }}"
alt="{{ app.name }}"
class="lazy-image"
{% if app.broken %}style="opacity: 0.4;"{% endif %}>
{% endif %}
</div>
<p {% if app.broken %}style="opacity: 0.5;"{% endif %}>{{ app.name }} - {{ app.summary }}</p>
{% if app.author %}
<p {% if app.broken %}style="opacity: 0.5;"{% endif %}>{{ _('From') }} {{ app.author }}</p>
{% endif %}
{% if app.broken %}
<div style="background-color: #f44336; color: white; padding: 5px 10px; font-weight: bold; border-radius: 3px; font-size: 12px; text-align: center; line-height: 1.3; margin-top: 5px;">
<i class="fa-solid fa-xmark" aria-hidden="true"></i> {{ _('BROKEN') }}<br>
<span style="font-size: 10px; font-weight: normal;">{{ app.brokenReason or 'Unknown' }}</span>
</div>
{% endif %}
{% if is_custom_apps %}
<a href="{{ url_for('deleteupload', filename=app.path.split('/')[-1], device_id=device.id) }}"
class="delete-upload-btn"
onclick="event.stopPropagation(); return confirm('{{ _('Delete this uploaded app?') }}');">
<i class="fa-solid fa-trash" aria-hidden="true"></i> {{ _('Delete') }}
</a>
{% endif %}
{% if config['PRODUCTION'] == '0' and not app.path.startswith("users/") %}
{% if app.broken %}
<button class="unmark-broken-btn" onclick="unmarkAppAsBroken('{{ app.file_name or app.name }}', '{{ app.package_name }}', event);" style="background-color: #4CAF50; color: white; border: none; padding: 5px 10px; cursor: pointer; margin-top: 5px; font-size: 12px;">{{ _('Unmark Broken') }}</button>
{% else %}
<button class="mark-broken-btn" onclick="markAppAsBroken('{{ app.file_name or app.name }}', '{{ app.package_name }}', event);" style="background-color: #f44336; color: white; border: none; padding: 5px 10px; cursor: pointer; margin-top: 5px; font-size: 12px;">{{ _('Mark Broken') }}</button>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endmacro %}
{% if custom_apps_list %}
{{ render_app_list(_('Custom Apps'), 'custom_search', 'custom_app_grid', custom_apps_list, show_controls=false, is_custom_apps=true) }}
<hr>
{% endif %}
{{ render_app_list(_('System Apps'), 'system_search', 'system_app_grid', apps_list, show_version=true, is_custom_apps=false) }}
<input type="hidden" name="name" id="selected_app">
<br>
<label for="uinterval">{{ _('Render Interval (minutes)') }}</label>
<input name="uinterval" type="number" id="uinterval" min="0" value="{{ request.form['uinterval'] or 10 }}" required>
<label for="display_time">{{ _('Display Time (seconds)') }}</label>
<input name="display_time" type="number" id="display_time" min="0" value="{{ request.form['display_time'] or 0 }}"
required>
<label for="notes">{{ _('Notes') }}</label>
<textarea name="notes" id="notes">{{ request.form['notes'] }}</textarea>
<button type="submit" class="w3-button w3-green" onclick="move()"><i class="fa-solid fa-sliders" aria-hidden="true"></i> {{ _('Configure') }}</button>
</form>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Hide loading indicator and show content
setTimeout(() => {
document.body.classList.add('content-loaded');
isInitialLoad = false;
// Initialize virtual scrolling first
setupVirtualScrolling();
// Initialize lazy loading
setupLazyLoading();
// Apply initial filters and sorting based on current state
applyFilters();
}, 100);
// Optimized click handlers with event delegation
document.addEventListener('click', function(e) {
const appItem = e.target.closest('.app-item');
if (!appItem) return;
// Don't allow clicking on broken apps
if (appItem.classList.contains('broken-app')) {
return;
}
// Remove selection from all items
document.querySelectorAll('.app-item').forEach(i => i.classList.remove('selected'));
// Add selection to clicked item
appItem.classList.add('selected');
document.getElementById('selected_app').value = appItem.getAttribute('data-value');
// Set the recommended interval from the app's metadata for display purposes
const recommendedInterval = appItem.getAttribute('data-recommended-interval');
if (recommendedInterval && recommendedInterval !== '0') {
document.getElementById('uinterval').value = recommendedInterval;
} else {
// Fallback to 10 if no recommended interval is set
document.getElementById('uinterval').value = 10;
}
// Open the form submission in a new tab
const form = document.getElementById('main_form');
form.target = '_blank';
form.submit();
// Reset target so future submissions work normally
form.target = '';
});
// Enhanced image loading with error handling
document.addEventListener('load', function(e) {
if (e.target.classList.contains('lazy-image')) {
e.target.classList.add('loaded');
const skeleton = e.target.parentElement.querySelector('.skeleton-loader');
if (skeleton) {
skeleton.style.display = 'none';
}
}
}, true);
document.addEventListener('error', function(e) {
if (e.target.classList.contains('lazy-image')) {
// Hide skeleton and show placeholder
const skeleton = e.target.parentElement.querySelector('.skeleton-loader');
if (skeleton) {
skeleton.style.display = 'none';
}
e.target.style.display = 'none';
}
}, true);
});
// Function to mark app as broken (development mode only)
function markAppAsBroken(appName, packageName, event) {
// Stop the event from bubbling up to the parent div
event.stopPropagation();
event.preventDefault();
if (!confirm("Mark '" + appName + "' as broken? This will add 'broken: true' to its manifest.yaml and prevent it from being installed.")) {
return;
}
let url = "{{ url_for('mark_app_broken', app_name='PLACEHOLDER') }}".replace('PLACEHOLDER', encodeURIComponent(appName));
if (packageName && packageName !== 'None' && packageName !== '') {
url += "?package_name=" + encodeURIComponent(packageName);
}
fetch(url, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert("App marked as broken successfully!");
location.reload();
} else {
alert("Error: " + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert("Failed to mark app as broken");
});
}
// Function to unmark app as broken (development mode only)
function unmarkAppAsBroken(appName, packageName, event) {
// Stop the event from bubbling up to the parent div
event.stopPropagation();
event.preventDefault();
if (!confirm("Unmark '" + appName + "' as broken? This will set 'broken: false' in its manifest.yaml and allow it to be installed.")) {
return;
}
let url = "{{ url_for('unmark_app_broken', app_name='PLACEHOLDER') }}".replace('PLACEHOLDER', encodeURIComponent(appName));
if (packageName && packageName !== 'None' && packageName !== '') {
url += "?package_name=" + encodeURIComponent(packageName);
}
fetch(url, {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert("App unmarked successfully!");
location.reload();
} else {
alert("Error: " + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert("Failed to unmark app");
});
}
</script>
{% endblock %}

View File

@@ -1,97 +0,0 @@
{% extends 'base.html' %}
{% block header %}
<h1>{{ _('Tronbyt Manager') }}</h1>
{% endblock %}
{% block content %}
{% if user %}
<br><a class="w3-button w3-purple w3-round w3-padding" href="{{ url_for('create') }}"><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> {{ _('New Tronbyt') }}</a>
<hr>
{% endif %}
{% for u in users %}
<div class="w3-card-4 w3-padding">
<header>
<h1>{{ u.username }}</h1>
{% if u.username != 'admin' %}
<form method="POST" action="{{ url_for('deleteuser', username=u.username) }}" onsubmit="return confirm('{{ _('Delete User?') }}');">
<input class="danger" type="submit" value="{{ _('Delete') }}">
</form>
{% endif %}
{% if u.devices %}
{% for device in u.devices.values() %}
<div class="w3-card-4 w3-padding">
<article>
<div>
<header>
<h1>{{ device.name }}</h1>
<table>
<tr>
<td>
<a class="w3-button w3-teal w3-round" style="min-width: 100px;"
href="{{ url_for('update', device_id=device.id) }}"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> {{ _('Edit') }}</a>
<a class="w3-button w3-teal w3-round" style="min-width: 100px;"
href="{{ url_for('addapp', device_id=device.id) }}"><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> {{ _('Add App') }}</a>
</td>
</tr>
</table>
</header>
<p class="body">{{ _('API ID:') }} {{ device.img_url }}</p>
<!-- <p class="body">API KEY: {{ device.api_key }}</p> -->
{% if device.notes %}
<p class="body">{{ _('Notes:') }} {{ device.notes }}</p>
{% endif %}
{% if device.apps %}
{% set apps_list = device.apps.values()|sort(attribute='order')|list %}
{% set pinned_app_iname = device.pinned_app %}
{% set pinned_apps = apps_list|selectattr('iname', 'equalto', pinned_app_iname) if pinned_app_iname else [] %}
{% set unpinned_apps = apps_list|rejectattr('iname', 'equalto', pinned_app_iname) %}
{% set all_apps_ordered = pinned_apps|list + unpinned_apps|list %}
{% for app in all_apps_ordered %}
<div class="w3-card-4 w3-padding">
<table width="100%">
<tr width="100%">
<td>
<ul>
<li>
<div class="post">
<header>
<h1>{{ app.iname }} &nbsp({{ _('installation id') }}) </h1>
<h1>{% if app.enabled %}<enabled>&nbsp -- {{ _('Enabled') }} --</enabled>{% else %}
<disabled>&nbsp -- {{ _('Disabled') }} --{% endif %}</disabled>
</h1>
</header>
<p class="body">{{ _('App:') }} {{ app.name }}</p>
<p class="body">{{ _('Render Interval (minutes):') }} {{ app.uinterval }}</p>
<p class="body">{{ _('Display Time (secs):') }} {{ app.display_time }}</p>
{% if app.notes %}
<p class="body">{{ _('Notes:') }} {{ app.notes }}</p>
{% endif %}
</td>
<td><br><div class="app-img"><img width="400" src="{{url_for('appwebp', device_id=device.id,iname=app.iname) }}" alt="{{ _('Preview') }}" ></div>
<br>
{{ _('Last Rendered:') }} {{ (app.last_render or 0)|timeago(request.state.babel.locale) }}
</div>
</li>
</td>
</tr>
</table>
</div>
{% if not loop.last %}
<hr>
{% endif %}
{% endfor %}
</ul>
{% endif %}
</article>
</div>
{% if not loop.last %}
<hr>
{% endif %}
{% endfor %}
{% endif %}
</div>
{% endfor %}
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,122 +0,0 @@
{% extends 'base.html' %}
{% block header %}
<link rel="stylesheet" href="{{ url_for('static', path='css/update-simple.css') }}">
<script src="{{ url_for('static', path='js/location.js') }}"></script>
<div class="header-container">
<h1 class="page-title">{% block title %}{{ _('New Tronbyt Device') }}{% endblock %}</h1>
</div>
<script>
function setBrightnessCreate(brightness) {
document.getElementById('brightness').value = brightness;
document.getElementById('brightness-output').innerText = brightness;
const buttons = document.querySelectorAll('#brightness-panel .brightness-btn');
buttons.forEach(btn => {
if (parseInt(btn.dataset.brightness) === parseInt(brightness)) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
</script>
{% endblock %}
{% block content %}
<form method="post" id="create-device-form">
<div class="device-settings-section">
<h2>{{ _('Basic Information') }}</h2>
<div class="device-settings-row">
<div>
<label for="name" class="device-settings-label">{{ _('Name') }}</label>
<input name="name" id="name" value="{{ form.name if form else '' }}" required class="device-settings-input">
<small class="small-text">{{ _('Descriptive name for this Tronbyt device') }}</small>
</div>
<div>
<label for="device_type" class="device-settings-label">{{ _('Device Type') }}</label>
<select name="device_type" id="device_type" class="device-settings-input">
{% for value, name in device_type_choices.items() %}
<option value="{{ value }}" {% if (form.device_type.value if form else default_device_type) == value %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="device-settings-section">
<h2>{{ _('Connection URLs') }}</h2>
<div class="device-settings-row">
<div>
<label for="img_url" class="device-settings-label">{{ _('Image URL') }}</label>
<input name="img_url" id="img_url" value="{{ form.img_url if form else '' }}" class="device-settings-input">
<small class="small-text">{{ _('Leave blank unless advanced user.') }}</small>
</div>
<div>
<label for="ws_url" class="device-settings-label">{{ _('WebSocket URL') }}</label>
<input name="ws_url" id="ws_url" value="{{ form.ws_url if form else '' }}" class="device-settings-input">
<small class="small-text">{{ _('Leave blank unless advanced user.') }}</small>
</div>
</div>
</div>
<div class="device-settings-section">
<h2>{{ _('Display Settings') }}</h2>
<table class="device-settings-table">
<tr>
<td>
<label class="device-settings-label">{{ _('Brightness Level (0 - 5)') }}</label>
</td>
<td>
<output id="brightness-output">{{ form.brightness if form else '3' }}</output>
<input type="hidden" name="brightness" id="brightness" value="{{ form.brightness if form else '3' }}">
<div class="brightness-panel" id="brightness-panel">
<button type="button" class="brightness-btn {% if (form.brightness if form else 3) == 0 %}active{% endif %}" data-brightness="0" onclick="setBrightnessCreate(0)">0</button>
<button type="button" class="brightness-btn {% if (form.brightness if form else 3) == 1 %}active{% endif %}" data-brightness="1" onclick="setBrightnessCreate(1)">1</button>
<button type="button" class="brightness-btn {% if (form.brightness if form else 3) == 2 %}active{% endif %}" data-brightness="2" onclick="setBrightnessCreate(2)">2</button>
<button type="button" class="brightness-btn {% if (form.brightness if form else 3) == 3 %}active{% endif %}" data-brightness="3" onclick="setBrightnessCreate(3)">3</button>
<button type="button" class="brightness-btn {% if (form.brightness if form else 3) == 4 %}active{% endif %}" data-brightness="4" onclick="setBrightnessCreate(4)">4</button>
<button type="button" class="brightness-btn {% if (form.brightness if form else 3) == 5 %}active{% endif %}" data-brightness="5" onclick="setBrightnessCreate(5)">5</button>
</div>
</td>
</tr>
</table>
</div>
<div class="device-settings-section">
<h2>{{ _('Location & Notes') }}</h2>
<div class="device-settings-row">
<div>
<label for="location_search" class="device-settings-label">{{ _('Location') }}</label>
<input type="text" id="location_search" placeholder="{{ _('Enter a location') }}"
value="{{ form.location_search if form else '' }}" class="device-settings-input">
<ul id="location_results"></ul>
<input type="hidden" name="location" id="location" value='{{ (form.location if form else {}) | tojson }}'>
</div>
<div>
<label for="notes" class="device-settings-label">{{ _('Notes') }}</label>
<input name="notes" id="notes" value="{{ form.notes if form else '' }}" class="device-settings-input" placeholder="{{ _('Optional notes about this device') }}">
</div>
</div>
</div>
<input type="hidden" name="api_key" id="api_key">
<div class="device-settings-section">
<div class="config-management-container" style="display:flex;align-items:center;gap:8px;">
<button type="submit" class="w3-button w3-green config-management-btn"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ _('Save') }}</button>
<button type="button" class="w3-button w3-orange config-management-btn"
onclick="window.location.href='{{ url_for('import_device') }}';">
<i class="fa-solid fa-file-import" aria-hidden="true"></i> {{ _('Import Configuration') }}
</button>
</div>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', function () {
enableLocationSearch(document.getElementById('location_search'), document.getElementById('location_results'), document.getElementById('location'), null);
});
</script>
{% endblock %}

View File

@@ -1,156 +0,0 @@
{% extends 'base.html' %}
{% block header %}
<link rel="stylesheet" href="{{ url_for('static', path='css/firmware-simple.css') }}">
<div class="header-container">
<h1 class="page-title">{% block title %}{{ _('Generate Firmware for') }} {{ device.name }} - {{ device_type_choices.get(device.type.value, device.type.value) }}{% endblock %}</h1>
</div>
{% endblock %}
{% block content %}
{% if not firmware_bins_available %}
<div class="w3-panel w3-yellow w3-border w3-border-amber w3-round-large">
<h3><i class="fa-solid fa-triangle-exclamation"></i> {{ _('Firmware Files Not Available') }}</h3>
<p>{{ _('Firmware binary files have not been downloaded yet. Firmware generation will not work until the files are downloaded.') }}</p>
{% if user.username == 'admin' %}
<p>
<strong>{{ _('Action Required:') }}</strong> {{ _('Please go to the') }}
<a href="{{ url_for('edit') }}#firmware-management" class="w3-text-blue" style="text-decoration: underline;">{{ _('Admin page') }}</a>
{{ _('to download the latest firmware files.') }}
</p>
{% else %}
<p>{{ _('Please contact your administrator to download the firmware files.') }}</p>
{% endif %}
</div>
{% endif %}
{% if firmware_version %}
<div class="firmware-info">
<div>
<strong>{{ _('Firmware Image Version') }}:</strong> {{ firmware_version }}
</div>
{% if user.username == 'admin' %}
<div>
<a href="{{ url_for('edit') }}#firmware-management" class="w3-button w3-small w3-gray firmware-management-btn">
<i class="fa-solid fa-screwdriver-wrench" aria-hidden="true"></i> {{ _('Manage Firmware') }}
</a>
</div>
{% endif %}
</div>
{% endif %}
<form method="post" id="firmware-form">
<div class="device-settings-section">
<h2>{{ _('Firmware Generation') }}</h2>
<div class="device-settings-section-divider">
<h3>{{ _('Download ESP Flasher') }}</h3>
<p>{{ _('Download ESP Flasher here') }} <a href="https://github.com/Jason2866/ESP_Flasher/releases" target="_blank">ESP Flasher</a></p>
<p>{{ _('Connect your device to your computer with a data USB cable, run the ESP Flasher program and select your downloaded firmware file to flash your device. Do NOT use a web based flasher.') }}</p>
<p><i>{{ _('macOS and Windows users may need to install a serial driver:') }}
<br>
<a href="https://www.silabs.com/developer-tools/usb-to-uart-bridge-vcp-drivers?tab=downloads" target="_blank">CP210x Drivers</a>
<br>
<a href="https://github.com/WCHSoftGroup/ch34xser_macos" target="_blank">CH34x Drivers</a>
</i></p>
</div>
<input type="hidden" name="id" id="id" value="{{ device.id }}">
<div class="device-settings-section-divider">
<h3>{{ _('Connection Settings') }}</h3>
<table class="device-settings-table">
<tr>
<td>
<label class="device-settings-label">{{ _('Connection Type') }}</label>
</td>
<td>
<div class="connection-type-container">
<label><input type="radio" name="url_variant" id="variant_ws" value="ws"> WebSocket</label>
<label><input type="radio" name="url_variant" id="variant_http" value="http"> HTTP</label>
<label><input type="radio" name="url_variant" id="variant_custom" value="custom"> Custom</label>
</div>
</td>
</tr>
<tr>
<td>
<label for="img_url" class="device-settings-label">{{ _('Image URL') }}</label>
</td>
<td>
<input name="img_url" id="img_url" value="{{ device.img_url }}" class="device-settings-input">
</td>
</tr>
</table>
</div>
<div class="device-settings-section-divider">
<h3>{{ _('WiFi Configuration') }}</h3>
<table class="device-settings-table">
<tr>
<td>
<label for="wifi_ap" class="device-settings-label">{{ _('WiFi Network Name (SSID) 2.4Ghz Only') }}</label>
</td>
<td>
<input name="wifi_ap" id="wifi_ap" required class="device-settings-input">
</td>
</tr>
<tr>
<td>
<label for="wifi_password" class="device-settings-label">{{ _('WiFi Password') }}</label>
</td>
<td>
<input name="wifi_password" id="wifi_password" required class="device-settings-input">
</td>
</tr>
{% if device.type == 'tidbyt_gen1' %}
<tr>
<td>
<label for="swap_colors" class="device-settings-label">{{ _('Swap Colors?') }}</label>
</td>
<td>
<input type="checkbox" name="swap_colors" id="swap_colors">
</td>
</tr>
{% endif %}
</table>
</div>
</div>
<div class="device-settings-section">
<div class="config-management-container">
<button class="w3-button w3-blue config-management-btn" type="submit"><i class="fa-solid fa-microchip" aria-hidden="true"></i> {{ _('Generate Firmware File') }}</button>
</div>
</div>
</form>
<hr>
<script>
(function () {
const input = document.getElementById('img_url');
const httpRadio = document.getElementById('variant_http');
const wsRadio = document.getElementById('variant_ws');
const customRadio = document.getElementById('variant_custom');
const httpVal = "{{ device.img_url }}";
const wsVal = "{{ device.ws_url }}";
function updateUrl() {
if (httpRadio.checked || wsRadio.checked) {
input.value = httpRadio.checked ? httpVal : wsVal;
input.readOnly = true;
} else if (customRadio.checked) {
input.value = "";
input.readOnly = false;
}
}
document.querySelectorAll('input[name="url_variant"]').forEach(radio => {
radio.addEventListener('change', updateUrl);
});
// Initialize
wsRadio.checked = true;
updateUrl();
})();
</script>
{% endblock %}

View File

@@ -1,13 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<h1>{{ _('Import Device Configuration') }}</h1>
<form method="post" enctype="multipart/form-data">
<div>
<label for="file">{{ _('Upload JSON File') }}</label>
<input type="file" name="file" id="file" accept=".json">
</div>
<button type="submit" class="w3-button w3-green"><i class="fa-solid fa-file-import" aria-hidden="true"></i> {{ _('Import Configuration') }}</button>
</form>
<a href="{{ url_for('index') }}" class="w3-button w3-gray"><i class="fa-solid fa-circle-xmark" aria-hidden="true"></i> {{ _('Cancel') }}</a>
{% endblock %}

View File

@@ -1,27 +0,0 @@
{% extends 'base.html' %}
{% block header %}
<!-- Add viewport meta tag -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Include external CSS and JS -->
<link rel="stylesheet" href="{{ url_for('static', path='css/manager.css') }}">
<script src="{{ url_for('static', path='js/manager.js') }}" defer></script>
{% endblock %}
{% block content %}
{% if user %}
<br><a class="w3-button w3-purple w3-round w3-padding" href="{{ url_for('create') }}"><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> {{ _('New Tronbyt') }}</a>
<hr>
{% endif %}
{% for item in devices_with_ui_scales %}
{% set device = item.device %}
{% set brightness_ui = item.brightness_ui %}
{% set night_brightness_ui = item.night_brightness_ui %}
{% include 'partials/device_card.html' %}
{% if not loop.last %}
<hr>
{% endif %}
{% endfor %}
{% endblock %}

View File

@@ -1,612 +0,0 @@
{% extends 'base.html' %}
{% block header %}
<link rel="stylesheet" href="{{ url_for('static', path='css/update-simple.css') }}">
<script src="{{ url_for('static', path='js/location.js') }}"></script>
<div class="header-container">
<h1 class="page-title">{% block title %}{{ _('Edit Device') }}: "{{ device.name }}"{% endblock %}</h1>
<div id="header-buttons"></div>
</div>
<script>
function setBrightnessUpdate(brightness) {
document.getElementById('brightness').value = brightness;
const buttons = document.querySelectorAll('#brightness-panel .brightness-btn');
buttons.forEach(btn => {
if (parseInt(btn.dataset.brightness) === parseInt(brightness)) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
function setNightBrightnessUpdate(brightness) {
document.getElementById('night_brightness').value = brightness;
const buttons = document.querySelectorAll('#night-brightness-panel .brightness-btn');
buttons.forEach(btn => {
if (parseInt(btn.dataset.brightness) === parseInt(brightness)) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
function setDimBrightnessUpdate(brightness) {
document.getElementById('dim_brightness').value = brightness;
const buttons = document.querySelectorAll('#dim-brightness-panel .brightness-btn');
buttons.forEach(btn => {
if (parseInt(btn.dataset.brightness) === parseInt(brightness)) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// Move buttons to header on page load
document.addEventListener('DOMContentLoaded', function() {
const headerButtons = document.getElementById('header-buttons');
const form = document.getElementById('device-form');
// Create Save button
const saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'w3-button w3-green config-management-btn';
saveBtn.innerHTML = '<i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ _('Save') }}';
saveBtn.onclick = function() {
saveDeviceForm();
};
// Create Delete button
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'w3-button w3-red config-management-btn';
deleteBtn.innerHTML = '<i class="fa-solid fa-trash" aria-hidden="true"></i> {{ _('Delete') }}';
deleteBtn.onclick = function() {
if (confirm('{{ _('Delete device and ALL apps? This action cannot be undone.') }}')) {
form.action = "{{ url_for('delete', device_id=device.id) }}";
form.method = 'post';
form.submit();
}
};
headerButtons.appendChild(saveBtn);
headerButtons.appendChild(deleteBtn);
});
</script>
{% endblock %}
{% block content %}
<form method="post" id="device-form">
<h1>{{ _('Device Settings') }}</h1>
<div class="device-settings-section">
<h2>{{ _('Basic Information') }}</h2>
<div class="device-settings-row">
<div>
<label for="name" class="device-settings-label">{{ _('Device Name') }}</label>
<input name="name" id="name" value="{{ request.form['name'] or device.name }}" required class="device-settings-input">
</div>
<div>
<label for="device_type" class="device-settings-label">{{ _('Device Type') }}</label>
<select name="device_type" id="device_type" class="device-settings-input">
{% for value, name in device_type_choices.items() %}
<option value="{{ value }}" {% if device.type.value == value %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="device-settings-section-divider">
<h3>{{ _('Connection URLs') }}</h3>
<table class="device-settings-table">
<tr>
<td>
<label for="img_url" class="device-settings-label">{{ _('Image URL') }}</label>
</td>
<td>
<div class="url-input-container">
<input name="img_url" id="img_url" value="{{ device.img_url }}"
oninput="updateExampleCommands()">
<button type="button" class="w3-button w3-small w3-gray url-reset-btn" onclick="resetImgUrl()"
title="{{ _('Reset to default') }}"><i class="fa-solid fa-rotate-left" aria-hidden="true"></i> {{ _('Reset') }}</button>
</div>
</td>
</tr>
<tr>
<td>
<label for="ws_url" class="device-settings-label">{{ _('Websocket URL') }}</label>
</td>
<td>
<div class="url-input-container">
<input name="ws_url" id="ws_url" value="{{ device.ws_url }}">
<button type="button" class="w3-button w3-small w3-gray url-reset-btn" onclick="resetWsUrl()"
title="{{ _('Reset to default') }}"><i class="fa-solid fa-rotate-left" aria-hidden="true"></i> {{ _('Reset') }}</button>
</div>
</td>
</tr>
</table>
</div>
</div>
<script>
function resetImgUrl() {
document.getElementById('img_url').value = '{{ default_img_url }}';
updateExampleCommands(); // Update example commands if they exist
}
function resetWsUrl() {
document.getElementById('ws_url').value = '{{ default_ws_url }}';
}
</script>
<div class="device-settings-section">
<h2>{{ _('Display Settings') }}</h2>
<table class="device-settings-table">
<tr>
<td>
<label for="default_interval" class="device-settings-label">{{ _('App Cycle Time (Seconds)') }}</label>
</td>
<td>
<div class="range-container">
<input type="range" name="default_interval" id="default_interval" min="1" max="30"
value="{{ device.default_interval }}" oninput="this.nextElementSibling.value = this.value">
<output class="range-output">{{ device.default_interval }}</output>
</div>
</td>
</tr>
<tr>
<td>
<label for="color_filter" class="device-settings-label">{{ _('Color Filter') }}</label>
<span class="tooltip-icon" title="{{ _('Apply custom filters to the image:
None: No transformation
Dimmed: Darkens image uniformly while preserving hue
Redshift: CCT-derived chromatic adaptation matrix ~3400K target
Warm: Adds a subtle warm, orange/yellow hue
Sunset: Emulates deep pink/orange of a setting sun
Sepia: Adds a warm, antique brown tone mimicking aged photographs
Vintage: Muted, brown/green nostalgic tones
Dusk: Fades brightness, adds reddish cast
Cool: Adds a cool, blue tint
Black & White: Converts image to perceptual grayscale using luminance weights
Ice: Pale desaturation with bluish cast
Moonlight: Dim blue-gray, night lighting effect
Neon: Boosts contrast, magenta-blue cyberpunk
Pastel: Softens tones, gentle highlight boost') }}"></span>
<small>{{ _('Override device color filter') }}</small>
</td>
<td>
<select name="color_filter" id="color_filter" class="device-settings-input">
{% for value, name in color_filter_choices.items() %}
<option value="{{ value }}" {% if device.color_filter==value %}selected{% endif %}>{{ _(name) }}</option>
{% endfor %}
</select>
</td>
</tr>
</table>
<div class="device-settings-section-divider">
<h3>{{ _('Brightness Settings') }}</h3>
<table class="device-settings-table">
<tr>
<td>
<label for="brightness" class="device-settings-label">{{ _('Brightness') }}</label>
</td>
<td>
<input type="hidden" name="brightness" id="brightness" value="{{ brightness_ui }}">
<div class="brightness-panel" id="brightness-panel">
<button type="button" class="brightness-btn {% if brightness_ui == 0 %}active{% endif %}"
data-brightness="0" onclick="setBrightnessUpdate(0)">0</button>
<button type="button" class="brightness-btn {% if brightness_ui == 1 %}active{% endif %}"
data-brightness="1" onclick="setBrightnessUpdate(1)">1</button>
<button type="button" class="brightness-btn {% if brightness_ui == 2 %}active{% endif %}"
data-brightness="2" onclick="setBrightnessUpdate(2)">2</button>
<button type="button" class="brightness-btn {% if brightness_ui == 3 %}active{% endif %}"
data-brightness="3" onclick="setBrightnessUpdate(3)">3</button>
<button type="button" class="brightness-btn {% if brightness_ui == 4 %}active{% endif %}"
data-brightness="4" onclick="setBrightnessUpdate(4)">4</button>
<button type="button" class="brightness-btn {% if brightness_ui == 5 %}active{% endif %}"
data-brightness="5" onclick="setBrightnessUpdate(5)">5</button>
</div>
</td>
</tr>
<tr>
<td>
<label for="use_custom_brightness_scale" class="device-settings-label">{{ _('Custom Brightness Scale') }}</label>
<span class="tooltip-icon" title="{{ _('Override the default brightness scale with custom values for each level (0-5)') }}"></span>
</td>
<td>
<div style="margin-bottom: 10px;">
<label style="display: inline-flex; align-items: center; cursor: pointer;">
<input type="checkbox" name="use_custom_brightness_scale" id="use_custom_brightness_scale"
{% if device.custom_brightness_scale %}checked{% endif %}
onchange="toggleCustomBrightnessInputs()">
<span style="margin-left: 8px;">{{ _('Use custom brightness scale') }}</span>
</label>
</div>
<div id="custom_brightness_inputs" style="{% if not device.custom_brightness_scale %}display: none;{% endif %}">
{% set default_scale = ['0', '3', '5', '12', '35', '100'] %}
{% set scale_values = device.custom_brightness_scale.split(',') if device.custom_brightness_scale else default_scale %}
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 10px;">
{% for i in range(6) %}
<div style="display: flex; align-items: center; gap: 8px;">
<label style="font-weight: bold; min-width: 60px;">{{ _('Level') }} {{ i }}:</label>
<input type="number" name="brightness_level_{{ i }}" id="brightness_level_{{ i }}"
value="{{ scale_values[i]|trim if i < scale_values|length else default_scale[i] }}"
min="0" max="100" step="1"
class="device-settings-input"
style="width: 80px; padding: 4px 8px;">
</div>
{% endfor %}
</div>
<div style="font-size: 0.85em; color: #666; margin-top: 5px;">
{{ _('Each value should be between 0-100. Higher levels should generally have higher values.') }}
</div>
</div>
<!-- Hidden input to store the comma-separated scale -->
<input type="hidden" name="custom_brightness_scale" id="custom_brightness_scale_hidden" value="{{ device.custom_brightness_scale if device.custom_brightness_scale else '' }}">
<div style="font-size: 0.85em; color: #666; margin-top: 10px; padding: 5px; background-color: #f5f5f5; border-radius: 3px;">
<strong>{{ _('Default system scale') }}:</strong>
<span style="display: inline-block; margin: 0 8px;"><strong>0:</strong> 0</span>
<span style="display: inline-block; margin: 0 8px;"><strong>1:</strong> 3</span>
<span style="display: inline-block; margin: 0 8px;"><strong>2:</strong> 5</span>
<span style="display: inline-block; margin: 0 8px;"><strong>3:</strong> 12</span>
<span style="display: inline-block; margin: 0 8px;"><strong>4:</strong> 35</span>
<span style="display: inline-block; margin: 0 8px;"><strong>5:</strong> 100</span>
</div>
<script>
function toggleCustomBrightnessInputs() {
const checkbox = document.getElementById('use_custom_brightness_scale');
const inputs = document.getElementById('custom_brightness_inputs');
inputs.style.display = checkbox.checked ? 'block' : 'none';
}
</script>
</td>
</tr>
</table>
</div>
</div>
<div class="device-settings-section">
<h2>{{ _('Interstitial App Settings') }}</h2>
<table class="device-settings-table">
<tr>
<td>
<label for="interstitial_enabled" class="device-settings-label">{{ _('Enable Interstitial App') }}</label>
</td>
<td>
<input type="checkbox" name="interstitial_enabled" id="interstitial_enabled" {% if device.interstitial_enabled %}checked{% endif %}>
<small class="small-text">{{ _('Display an interstitial app between each regular app in the rotation.') }}</small>
</td>
</tr>
</table>
<div class="device-settings-section-divider">
<h3>{{ _('Interstitial App Configuration') }}</h3>
<table class="device-settings-table">
<tr>
<td>
<label for="interstitial_app" class="device-settings-label">{{ _('Interstitial App') }}</label>
</td>
<td>
<select name="interstitial_app" id="interstitial_app" class="select-container">
<option value="None">{{ _('None') }}</option>
{% if device.apps %}
{% for app in device.apps.values() %}
<option value="{{ app.iname }}" {% if device.interstitial_app and device.interstitial_app == app.iname %}selected{% endif %}>
{{ app.iname }} {{ app.name }}
</option>
{% endfor %}
{% endif %}
</select>
<small class="small-text">{{ _('The interstitial app will display even if it is disabled in the regular rotation.') }}</small>
</td>
</tr>
</table>
</div>
</div>
<div class="device-settings-section">
<h2>{{ _('Night Mode Settings') }}</h2>
<table class="device-settings-table">
<tr>
<td>
<label for="night_mode_enabled" class="device-settings-label">{{ _('Enable Night Mode') }}</label>
</td>
<td>
<input type="checkbox" name="night_mode_enabled" id="night_mode_enabled" {% if device.night_mode_enabled %}checked{% endif %}>
</td>
</tr>
</table>
<div class="device-settings-section-divider">
<h3>{{ _('Night Mode Configuration') }}</h3>
<table class="device-settings-table">
<tr>
<td>
<label for="night_start" class="device-settings-label">{{ _('Night Start Time') }}</label>
</td>
<td>
<input type="time" name="night_start" id="night_start" value="{{ device.night_start or '22:00' }}" class="device-settings-input">
</td>
</tr>
<tr>
<td>
<label for="night_end" class="device-settings-label">{{ _('Night End Time') }}</label>
</td>
<td>
<input type="time" name="night_end" id="night_end" value="{{ device.night_end or '06:00' }}" class="device-settings-input">
</td>
</tr>
<tr>
<td>
<label for="night_brightness" class="device-settings-label">{{ _('Night Brightness') }}</label>
</td>
<td>
<input type="hidden" name="night_brightness" id="night_brightness" value="{{ night_brightness_ui }}">
<div class="brightness-panel" id="night-brightness-panel">
<button type="button" class="brightness-btn {% if night_brightness_ui == 0 %}active{% endif %}"
data-brightness="0" onclick="setNightBrightnessUpdate(0)">0</button>
<button type="button" class="brightness-btn {% if night_brightness_ui == 1 %}active{% endif %}"
data-brightness="1" onclick="setNightBrightnessUpdate(1)">1</button>
<button type="button" class="brightness-btn {% if night_brightness_ui == 2 %}active{% endif %}"
data-brightness="2" onclick="setNightBrightnessUpdate(2)">2</button>
<button type="button" class="brightness-btn {% if night_brightness_ui == 3 %}active{% endif %}"
data-brightness="3" onclick="setNightBrightnessUpdate(3)">3</button>
<button type="button" class="brightness-btn {% if night_brightness_ui == 4 %}active{% endif %}"
data-brightness="4" onclick="setNightBrightnessUpdate(4)">4</button>
<button type="button" class="brightness-btn {% if night_brightness_ui == 5 %}active{% endif %}"
data-brightness="5" onclick="setNightBrightnessUpdate(5)">5</button>
</div>
</td>
</tr>
<tr>
<td>
<label for="night_mode_app" class="device-settings-label">{{ _('Night Mode App') }}</label>
</td>
<td>
<select name="night_mode_app" id="night_mode_app" class="select-container">
<option value="None">{{ _('None') }}</option>
{% if device.apps %}
{% for app in device.apps.values() %}
<option value="{{ app.iname }}" {% if device.night_mode_app and device.night_mode_app==app.iname %}selected{% endif %}>
{{ app.iname }} {{ app.name }}
</option>
{% endfor %}
{% endif %}
</select>
<small class="small-text">{{ _('To prevent the night mode app from displaying during the day, set it to disabled on the app edit page.') }}</small>
</td>
</tr>
<tr>
<td>
<label for="night_color_filter" class="device-settings-label">{{ _('Night Color Filter') }}</label>
<span class="tooltip-icon" title="{{ _('Apply custom filters to the image during night mode.') }}"></span>
</td>
<td>
<select name="night_color_filter" id="night_color_filter" class="device-settings-input">
{% for value, name in color_filter_choices.items() %}
<option value="{{ value }}" {% if device.night_color_filter==value %}selected{% endif %}>{{ _(name) }}</option>
{% endfor %}
</select>
</td>
</tr>
</table>
</div>
</div>
<div class="device-settings-section">
<h2>{{ _('Dim Mode Settings') }}</h2>
<table class="device-settings-table">
<tr>
<td>
<label for="dim_time" class="device-settings-label">{{ _('Dim Start Time') }}</label>
</td>
<td>
<input type="time" name="dim_time" id="dim_time" value="{{ device.dim_time or '' }}" class="device-settings-input">
<small class="small-text">{{ _('Leave empty to disable dim mode. Dim mode ends at Night End Time (if set) or 6:00 AM by default.') }}</small>
</td>
</tr>
<tr>
<td>
<label for="dim_brightness" class="device-settings-label">{{ _('Dim Brightness') }}</label>
</td>
<td>
<input type="hidden" name="dim_brightness" id="dim_brightness" value="{{ device.dim_brightness.as_ui_scale if device.dim_brightness is not none else 2 }}">
<div class="brightness-panel" id="dim-brightness-panel">
<button type="button" class="brightness-btn {% if (device.dim_brightness.as_ui_scale if device.dim_brightness is not none else 2) == 0 %}active{% endif %}"
data-brightness="0" onclick="setDimBrightnessUpdate(0)">0</button>
<button type="button" class="brightness-btn {% if (device.dim_brightness.as_ui_scale if device.dim_brightness is not none else 2) == 1 %}active{% endif %}"
data-brightness="1" onclick="setDimBrightnessUpdate(1)">1</button>
<button type="button" class="brightness-btn {% if (device.dim_brightness.as_ui_scale if device.dim_brightness is not none else 2) == 2 %}active{% endif %}"
data-brightness="2" onclick="setDimBrightnessUpdate(2)">2</button>
<button type="button" class="brightness-btn {% if (device.dim_brightness.as_ui_scale if device.dim_brightness is not none else 2) == 3 %}active{% endif %}"
data-brightness="3" onclick="setDimBrightnessUpdate(3)">3</button>
<button type="button" class="brightness-btn {% if (device.dim_brightness.as_ui_scale if device.dim_brightness is not none else 2) == 4 %}active{% endif %}"
data-brightness="4" onclick="setDimBrightnessUpdate(4)">4</button>
<button type="button" class="brightness-btn {% if (device.dim_brightness.as_ui_scale if device.dim_brightness is not none else 2) == 5 %}active{% endif %}"
data-brightness="5" onclick="setDimBrightnessUpdate(5)">5</button>
</div>
</td>
</tr>
</table>
</div>
<div class="device-settings-section">
<h2>{{ _('Location & API Settings') }}</h2>
<table class="device-settings-table">
<tr>
<td>
<label for="location_search" class="device-settings-label">{{ _('Location') }}</label>
</td>
<td>
<input type="text" id="location_search" placeholder="{{ _('Enter a location') }}"
value="{{ request.form['location_search'] or device.location.description if device.location else '' }}" class="device-settings-input">
<ul id="location_results"></ul>
<input type="hidden" name="location" id="location"
value='{{ (request.form["location"] or device.location.model_dump() if device.location else {}) | tojson }}'>
</td>
</tr>
<tr>
<td>
<label for="locale" class="device-settings-label">{{ _('Locale') }}</label>
</td>
<td>
<input type="text" name="locale" id="locale" value="{{ device.locale or '' }}" class="device-settings-input" placeholder="e.g. en_US" list="locale-list">
<datalist id="locale-list">
{% for loc in available_locales %}
<option value="{{ loc }}"></option>
{% endfor %}
</datalist>
<small class="small-text">{{ _('Locale for apps (e.g. en_US, de_DE).') }}</small>
</td>
</tr>
</table>
<div class="device-settings-section-divider">
<h3>{{ _('API Configuration') }}</h3>
<table class="device-settings-table">
<tr>
<td>
<label for="device_id" class="device-settings-label">{{ _('Device ID') }}</label>
</td>
<td>
<input id="device_id" value="{{ device.id }}" readonly class="device-settings-input">
</td>
</tr>
<tr>
<td>
<label for="api_key" class="device-settings-label">{{ _('Device API Key') }}</label>
</td>
<td>
<input name="api_key" id="api_key" value="{{ request.form['api_key'] or device.api_key }}"
oninput="updateExampleCommands()" class="device-settings-input">
</td>
</tr>
<tr>
<td>
<label for="curl_example" class="device-settings-label">{{ _('Example curl Command') }}</label>
</td>
<td>
<textarea id="curl_example" rows="3" readonly class="device-example-command-input"></textarea>
</td>
</tr>
<tr>
<td>
<label for="pixlet_example" class="device-settings-label">{{ _('Example pixlet Command') }}</label>
</td>
<td>
<textarea id="pixlet_example" rows="3" readonly class="device-example-command-input"></textarea>
</td>
</tr>
</table>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
enableLocationSearch(document.getElementById('location_search'), document.getElementById('location_results'), document.getElementById('location'), null);
});
</script>
<script>
function parseImageUrl(imageUrl) {
try {
const url = new URL(imageUrl);
const pathParts = url.pathname.split("/").filter(Boolean);
const deviceId = pathParts[0];
const server = `${url.protocol}//${url.host}`;
return { server, deviceId };
} catch (e) {
return { server: 'http://localhost:8000', deviceId: 'UNKNOWN' };
}
}
function updateExampleCommands() {
const apiKey = document.getElementById("api_key").value.trim();
const imageUrl = document.getElementById("img_url").value.trim();
const { server, deviceId } = parseImageUrl(imageUrl);
const curl = `curl -X POST ${server}/v0/devices/${deviceId}/push -H 'Authorization: ${apiKey || 'CHANGEME'}' -H 'Content-Type: application/json' --data '{"image": "'$(base64 -w 0 -i test.webp)'"}'`;
document.getElementById("curl_example").value = curl;
const pixlet = `pixlet push -u ${server} -t '${apiKey || 'CHANGEME'}' ${deviceId} test.webp`;
document.getElementById("pixlet_example").value = pixlet;
}
// Update curl command on page load
document.addEventListener("DOMContentLoaded", updateExampleCommands);
</script>
<div class="device-settings-section">
<h2>{{ _('Additional Settings') }}</h2>
<table class="device-settings-table">
<tr>
<td>
<label for="notes" class="device-settings-label">{{ _('Notes') }}</label>
</td>
<td>
<input name="notes" id="notes" value="{{ request.form['notes'] or device.notes }}" class="notes-input" placeholder="{{ _('Optional notes about this device') }}">
</td>
</tr>
</table>
</div>
<div class="device-settings-section">
<h2>{{ _('Configuration Management') }}</h2>
<div class="config-management-container">
<a href="{{ url_for('export_device_config', device_id=device.id) }}" class="w3-button w3-blue config-management-btn">
<i class="fa-solid fa-download" aria-hidden="true"></i> {{ _('Export Configuration') }}
</a>
<a href="{{ url_for('import_device_config', device_id=device.id) }}" class="w3-button w3-orange config-management-btn">
<i class="fa-solid fa-upload" aria-hidden="true"></i> {{ _('Import Configuration') }}
</a>
</div>
</div>
<div class="button-container">
<button type="button" class="w3-button w3-green config-management-btn" onclick="saveDeviceForm();"><i class="fa-solid fa-floppy-disk" aria-hidden="true"></i> {{ _('Save') }}</button>
<button type="button" class="w3-button w3-red config-management-btn" onclick="if (confirm('{{ _('Delete device and ALL apps? This action cannot be undone.') }}')) { this.form.action = '{{ url_for('delete', device_id=device.id) }}'; this.form.method = 'post'; this.form.submit(); }"><i class="fa-solid fa-trash" aria-hidden="true"></i> {{ _('Delete') }}</button>
</div>
<script>
function saveDeviceForm() {
// Update the hidden custom brightness scale field before submitting
const checkbox = document.getElementById('use_custom_brightness_scale');
const hiddenInput = document.getElementById('custom_brightness_scale_hidden');
if (checkbox && checkbox.checked) {
const values = [];
for (let i = 0; i < 6; i++) {
const input = document.getElementById('brightness_level_' + i);
if (input) {
values.push(input.value);
}
}
hiddenInput.value = values.join(',');
} else if (hiddenInput) {
hiddenInput.value = '';
}
// Submit the form
document.getElementById('device-form').submit();
}
</script>
</form>
{% endblock %}

View File

@@ -1,16 +0,0 @@
{% extends 'base.html' %}
{% block header %}
<h1>{% block title %}{{ _('Upload App (.star files only)') }}{% endblock %}</h1>
{% endblock %}
{% block content %}
{% if user.username == "admin" %}
{% endif %}
<form method="POST" enctype="multipart/form-data">
<input type="file" name=file>
<button type="submit" class="w3-button w3-green"><i class="fa-solid fa-cloud-arrow-up" aria-hidden="true"></i> {{ _('Upload') }}</button>
</form>
<h3>{{ _('Current files:') }}</h3>
{% for filename in files %}
<p>{{ filename }} - <a href="{{ url_for('deleteupload', filename=filename, device_id=device_id) }}"><i class="fa-solid fa-trash" aria-hidden="true"></i> {{ _('Delete') }}</a></p>
{% endfor %}
{% endblock %}

View File

@@ -1,108 +0,0 @@
<!-- App Card Component -->
<div class="w3-card-4 w3-padding app-card" draggable="true" data-iname="{{ app.iname }}">
<div class="app-card-content">
<!-- Left: Preview Image -->
<div class="app-preview">
<div class="app-img">
<img src="{{url_for('appwebp', device_id=device.id,iname=app.iname) }}"
alt="{{ _('Preview') }}">
{% if app.empty_last_render %}
<div class="inactive-overlay">
<span class="inactive-badge-overlay"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> {{ _('INACTIVE') }}</span>
</div>
{% endif %}
</div>
</div>
<!-- Middle: App Info and Action Buttons -->
<div class="app-info">
<div class="app-header">
<h3>{{ app.name }}-{{ app.iname }}</h3>
<div class="app-status">
{% if device.pinned_app == app.iname %}
<span class="status-badge pinned"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> {{ _('PINNED') }}</span>
{% else %}
{% if app.enabled %}
<span class="status-badge enabled"><i class="fa-solid fa-circle-check" aria-hidden="true"></i> {{ _('ENABLED') }}</span>
{% else %}
<span class="status-badge disabled"><i class="fa-solid fa-circle-xmark" aria-hidden="true"></i> {{ _('DISABLED') }}</span>
{% endif %}
{% endif %}
{% if app.autopin and (device.pinned_app == app.iname or app.enabled) %}
<span class="status-badge autopin"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> {{ _('AUTOPIN') }}</span>
{% endif %}
</div>
</div>
<div class="app-details">
<p class="app-render-info">
{{ _('Interval:') }} {{ app.uinterval }} min |
{% if app.empty_last_render %}{{_('No output for ') }}{% endif %}
{{ _('Last:') }} {{ (app.last_render or 0)|timeago(request.state.babel.locale) }}
{% if app.last_render_duration %}
({{ _('in') }} {{ app.last_render_duration|duration(request.state.babel.locale) }})
{% endif %}
</p>
{% if app.display_time != 0 %}
<p>{{ _('Display Time (secs):') }} {{ app.display_time }}</p>
{% endif %}
{% if app.notes and app.notes != '' %}
<p>{{ _('Notes:') }} {{ app.notes }}</p>
{% endif %}
</div>
<div class="app-actions">
<!-- Primary Actions Row -->
<div class="app-actions-primary">
<a class="action w3-button w3-blue w3-round"
href="{{ url_for('configapp', device_id=device.id, iname=app.iname) }}?delete_on_cancel=False"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> {{ _('Edit') }}</a>
<button class="action w3-button w3-blue w3-round"
onclick="previewApp('{{ device.id }}', '{{ app.iname }}', null, this, { previewing: '{{ _('Previewing...') }}', sent: '{{ _('Sent') }}', failed: '{{ _('Failed') }}' })"><i class="fa-solid fa-play" aria-hidden="true"></i> {{ _('Preview') }}</button>
<button class="action w3-button w3-green w3-round"
onclick="duplicateApp('{{ device.id }}', '{{ app.iname }}')"><i class="fa-solid fa-copy" aria-hidden="true"></i> {{ _('Duplicate') }}</button>
<div class="copy-to-dropdown">
<select class="action w3-button w3-indigo w3-round custom-select"
onchange="if(this.value) { duplicateAppToDevice('{{ device.id }}', '{{ app.iname }}', this.value, null, false); this.selectedIndex=0; }">
<option value="" disabled selected>{{ _('Copy to...') }}</option>
{% for item in devices_with_ui_scales %}
{% set target_device = item.device %}
{% if target_device.id != device.id %}
<option value="{{ target_device.id }}">{{ target_device.name }}</option>
{% endif %}
{% endfor %}
</select>
<i class="fa fa-caret-down custom-select-arrow"></i>
</div>
<button class="action w3-button w3-red w3-round"
onclick="deleteApp('{{ device.id }}', '{{ app.iname }}')"><i class="fa-solid fa-trash" aria-hidden="true"></i> {{ _('Delete') }}</button>
</div>
<!-- Secondary Actions Row -->
<div class="app-actions-secondary">
<button class="action w3-button w3-blue w3-round app-btn-no-hover {% if loop.first %}w3-disabled{% endif %}"
{% if loop.first %}disabled{% endif %}
onclick="{% if not loop.first %}moveApp('{{ device.id }}', '{{ app.iname }}', 'top'){% endif %}"><i class="fa-solid fa-angles-up" aria-hidden="true"></i> {{ _('Top') }}</button>
<button class="action w3-button w3-blue w3-round app-btn-no-hover {% if loop.last %}w3-disabled{% endif %}"
{% if loop.last %}disabled{% endif %}
onclick="{% if not loop.last %}moveApp('{{ device.id }}', '{{ app.iname }}', 'bottom'){% endif %}"><i class="fa-solid fa-angles-down" aria-hidden="true"></i> {{ _('Bottom') }}</button>
{% if device.pinned_app == app.iname %}
<button class="action w3-button w3-orange w3-round app-btn-no-hover"
onclick="togglePin('{{ device.id }}', '{{ app.iname }}')"><i class="fa-solid fa-thumbtack-slash" aria-hidden="true"></i> {{ _('Unpin') }}</button>
{% else %}
<button class="action w3-button w3-green w3-round app-btn-no-hover"
onclick="togglePin('{{ device.id }}', '{{ app.iname }}')"><i class="fa-solid fa-thumbtack" aria-hidden="true"></i> {{ _('Pin') }}</button>
{% if app.enabled %}
<button class="action w3-button w3-orange w3-round app-btn-no-hover"
onclick="toggleEnabled('{{ device.id }}', '{{ app.iname }}')"><i class="fa-solid fa-pause" aria-hidden="true"></i> {{ _('Disable') }}</button>
{% else %}
<button class="action w3-button w3-green w3-round app-btn-no-hover"
onclick="toggleEnabled('{{ device.id }}', '{{ app.iname }}')"><i class="fa-solid fa-play" aria-hidden="true"></i> {{ _('Enable') }}</button>
{% endif %}
{% endif %}
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,144 +0,0 @@
<!-- Device Card Component -->
<div class="w3-card-4 w3-padding device-container">
<article>
<div>
<table width="100%">
<tr>
<td colspan="2">
<header>
<h1>
<a href="{{ device.img_url }}" target="_blank" class="device-link">
{{ device.name }}
</a>
{% if device.pinned_app %}
{% set pinned_app_iname = device.pinned_app %}
{% set pinned_app = device.apps.get(pinned_app_iname) if device.apps else None %}
{% if pinned_app %}
<span class="pinned-badge">
<i class="fa-solid fa-thumbtack" aria-hidden="true"></i> {{ _('Pinned:') }} {{ pinned_app.name }}
</span>
{% endif %}
{% endif %}
</h1>
</header>
</td>
<td rowspan="4" width="40%">
<div class="app-img"><img id="currentWebp-{{ device.id }}"
data-src="{{url_for('currentwebp', device_id=device.id) }}"
src="{{url_for('currentwebp', device_id=device.id) }}"
alt="{{ _('Preview') }}">
</div>
<div>{{ _('Currently Displaying App') }}</div>
<div class="device-info-box">
<button class="device-info-toggle" aria-expanded="false" aria-controls="device-info-content-{{ device.id }}">
<i class="fas fa-chevron-down"></i> {{ _('Device Info') }}
</button>
<div id="device-info-content-{{ device.id }}" class="device-info-content">
<table class="device-info-table">
{% if device.info.firmware_version %}
<tr><td>{{ _('Firmware Version:') }}</td><td>{{ device.info.firmware_version }}</td></tr>
{% endif %}
{% if device.info.firmware_type %}
<tr><td>{{ _('Firmware Type:') }}</td><td>{{ device.info.firmware_type }}</td></tr>
{% endif %}
{% if device.info.protocol_version %}
<tr><td>{{ _('Protocol Version:') }}</td><td>{{ device.info.protocol_version }}</td></tr>
{% endif %}
{% if device.info.mac_address %}
<tr><td>{{ _('MAC Address:') }}</td><td>{{ device.info.mac_address }}</td></tr>
{% endif %}
{% if device.info.protocol_type %}
<tr><td>{{ _('Protocol Type:') }}</td><td>{{ device.info.protocol_type.value }}</td></tr>
{% endif %}
{% if device.last_seen %}
<tr><td>{{ _('Last Seen:') }}</td><td>{{ device.last_seen.astimezone().strftime('%Y-%m-%d %H:%M:%S') }}</td></tr>
{% endif %}
</table>
</div>
</div>
</td>
</tr>
<tr>
<td>
<label>{{ _('Brightness') }}</label> =
<span id="brightnessValue-{{ device.id }}">{{ brightness_ui }}</span>
<div class="brightness-panel" id="brightness-panel-{{ device.id }}">
<button type="button" class="brightness-btn {% if brightness_ui == 0 %}active{% endif %}"
data-brightness="0" onclick="setBrightness('{{ device.id }}', 0)">0</button>
<button type="button" class="brightness-btn {% if brightness_ui == 1 %}active{% endif %}"
data-brightness="1" onclick="setBrightness('{{ device.id }}', 1)">1</button>
<button type="button" class="brightness-btn {% if brightness_ui == 2 %}active{% endif %}"
data-brightness="2" onclick="setBrightness('{{ device.id }}', 2)">2</button>
<button type="button" class="brightness-btn {% if brightness_ui == 3 %}active{% endif %}"
data-brightness="3" onclick="setBrightness('{{ device.id }}', 3)">3</button>
<button type="button" class="brightness-btn {% if brightness_ui == 4 %}active{% endif %}"
data-brightness="4" onclick="setBrightness('{{ device.id }}', 4)">4</button>
<button type="button" class="brightness-btn {% if brightness_ui == 5 %}active{% endif %}"
data-brightness="5" onclick="setBrightness('{{ device.id }}', 5)">5</button>
</div>
</td>
</tr>
<tr>
<td>
<label for="default_interval-{{ device.id }}">{{ _('App Cycle Time (Seconds)') }}</label> = <span
id="intervalValue-{{ device.id }}">{{
device.default_interval }} </span> s<br>
<input type="range" name="default_interval" id="default_interval-{{ device.id }}" min="1" max="30"
value="{{ device.default_interval }}" oninput="updateIntervalValue('{{ device.id }}', this.value)"
onmouseup="updateInterval('{{ device.id }}', this.value)">
</td>
</tr>
<tr>
<td>
<a class="w3-button w3-teal w3-round" style="min-width: 100px;"
href="{{ url_for('addapp', device_id=device.id) }}"><i class="fa-solid fa-circle-plus" aria-hidden="true"></i> {{ _('Add App') }}</a>
<a class="w3-button w3-teal w3-round" style="min-width: 100px;"
href="{{ url_for('update', device_id=device.id) }}"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i> {{ _('Edit Device') }}</a>
{% if device.type in ["tidbyt_gen1", "tidbyt_gen2", "pixoticker", "tronbyt_s3", "tronbyt_s3_wide", "matrixportal_s3", "matrixportal_s3_waveshare"] %}
<a class="w3-button w3-teal w3-round" style="min-width: 100px;"
href="{{ url_for('generate_firmware', device_id=device.id) }}"><i class="fa-solid fa-microchip" aria-hidden="true"></i> {{ _('Firmware') }}</a>
{% endif %}
</tr>
</table>
<!-- View Toggle Row -->
<div class="view-toggle-row" style="display: flex; justify-content: space-between; align-items: center;">
<div class="view-toggle-container" style="display: flex; align-items: center;">
<span style="margin-right: 10px; font-weight: bold;">{{ _('View:') }}</span>
<button id="listViewBtn-{{ device.id }}" class="w3-button w3-blue w3-round view-toggle-btn active"
onclick="switchToListView('{{ device.id }}')" title="{{ _('List View') }}">
<i class="fas fa-list"></i> {{ _('List') }}
</button>
<button id="gridViewBtn-{{ device.id }}" class="w3-button w3-blue w3-round view-toggle-btn"
onclick="switchToGridView('{{ device.id }}')" title="{{ _('Grid View') }}">
<i class="fas fa-th"></i> {{ _('Grid') }}
</button>
<button id="collapsedViewBtn-{{ device.id }}" class="w3-button w3-blue w3-round view-toggle-btn"
onclick="switchToCollapsedView('{{ device.id }}')" title="{{ _('Collapsed View') }}">
<i class="fas fa-chevron-up"></i> {{ _('Collapsed') }}
</button>
</div>
<div class="instruction-text" style="color: #666; font-style: italic;">
{{ _('Drag apps to reorder or copy from/to another device') }}
</div>
</div>
<div id="appsList-{{ device.id }}" class="visible apps-list-view">
{% set apps_list = (device.apps.values()|list if device.apps else [])|sort(attribute='order') %}
{% set pinned_app_iname = device.pinned_app %}
{% set pinned_apps = apps_list|selectattr('iname', 'equalto', pinned_app_iname) if pinned_app_iname else [] %}
{% set unpinned_apps = apps_list|rejectattr('iname', 'equalto', pinned_app_iname) %}
{% set all_apps_ordered = pinned_apps|list + unpinned_apps|list %}
{% for app in all_apps_ordered %}
{% include 'partials/app_card.html' %}
{% if not loop.last %}
<hr class="list-view-separator">
{% endif %}
{% endfor %}
</div>
</div>
</article>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,355 +0,0 @@
"""Utility functions."""
import logging
import os
import shutil
import sqlite3
import time
from datetime import timedelta
from enum import Enum
from pathlib import Path
from typing import Any
from fastapi import Request, Response
from fastapi.responses import FileResponse
from fastapi_babel import _
from git import FetchInfo, GitCommandError, Repo
from werkzeug.utils import secure_filename
from tronbyt_server import db
from tronbyt_server.flash import flash
from tronbyt_server.git_utils import get_primary_remote, get_repo
from tronbyt_server.models import App, Device, User, ProtocolType
from tronbyt_server.pixlet import render_app as pixlet_render_app
from tronbyt_server.models.sync import SyncPayload
from tronbyt_server.sync import get_sync_manager
from tronbyt_server.models.app import ColorFilter
from tronbyt_server.db import get_night_mode_is_active
logger = logging.getLogger(__name__)
MODULE_ROOT = Path(__file__).parent.resolve()
class RepoStatus(Enum):
CLONED = "cloned"
UPDATED = "updated"
REMOVED = "removed"
FAILED = "failed"
NO_CHANGE = "no_change"
def add_default_config(config: dict[str, Any], device: Device) -> dict[str, Any]:
"""Add default configuration values to an app's config."""
config["$tz"] = db.get_device_timezone_str(device)
return config
def render_app(
db_conn: sqlite3.Connection,
app_path: Path,
config: dict[str, Any],
webp_path: Path | None,
device: Device,
app: App | None,
user: User,
) -> bytes | None:
"""Renders a pixlet app to a webp image."""
if app_path.suffix.lower() == ".webp":
if app_path.exists():
image_data = app_path.read_bytes()
if webp_path:
webp_path.write_bytes(image_data)
return image_data
else:
logger.error(f"Webp file not found at {app_path}")
return None
config_data = config.copy()
add_default_config(config_data, device)
tz = config_data.get("$tz")
if not app_path.is_absolute():
app_path = db.get_data_dir() / app_path
device_interval = device.default_interval or 15
app_interval = (app and app.display_time) or device_interval
# Check if night mode is active
night_mode = get_night_mode_is_active(device)
# Determine base device filter (night or regular)
device_filter = (
device.night_color_filter
if night_mode and device.night_color_filter
else device.color_filter
)
color_filter = device_filter
if app and app.color_filter and app.color_filter != ColorFilter.INHERIT:
color_filter = app.color_filter
# Prepare filters for pixlet
# Only apply if we have a filter and it's not NONE
filters = (
{"color_filter": color_filter.value}
if color_filter and color_filter != ColorFilter.NONE
else None
)
data, messages = pixlet_render_app(
path=app_path,
config=config_data,
width=64,
height=32,
maxDuration=app_interval * 1000,
timeout=30000,
image_format=0,
supports2x=device.supports_2x(),
filters=filters,
tz=tz,
locale=device.locale,
)
if data is None:
logger.error("Error running pixlet render")
return None
if messages and app is not None:
db.save_render_messages(db_conn, user, device, app, messages)
if len(data) > 0 and webp_path:
webp_path.write_bytes(data)
return data
def set_repo(
request: Request,
apps_path: Path,
old_repo_url: str,
repo_url: str,
) -> bool:
"""Clone or update a git repository."""
status = RepoStatus.NO_CHANGE
if repo_url:
repo = get_repo(apps_path)
# If repo URL has changed, or path is not a valid git repo, then re-clone.
if old_repo_url != repo_url or not repo:
if apps_path.exists():
shutil.rmtree(apps_path)
try:
Repo.clone_from(repo_url, apps_path, depth=1)
status = RepoStatus.CLONED
except GitCommandError:
status = RepoStatus.FAILED
else:
# Repo exists and URL is the same, so pull changes.
try:
remote = get_primary_remote(repo)
if remote:
fetch_info = remote.pull()
if all(info.flags & FetchInfo.HEAD_UPTODATE for info in fetch_info):
status = RepoStatus.NO_CHANGE
else:
status = RepoStatus.UPDATED
else:
logger.warning(
f"No remote found to pull from for repo at {apps_path}"
)
status = RepoStatus.FAILED
except GitCommandError:
status = RepoStatus.FAILED
elif old_repo_url:
if apps_path.exists():
shutil.rmtree(apps_path)
status = RepoStatus.REMOVED
messages = {
RepoStatus.CLONED: _("Repo Cloned"),
RepoStatus.UPDATED: _("Repo Updated"),
RepoStatus.REMOVED: _("Repo removed"),
RepoStatus.FAILED: _("Error Cloning or Updating Repo"),
RepoStatus.NO_CHANGE: _("No Changes to Repo"),
}
flash(request, messages[status])
return status != RepoStatus.FAILED
def possibly_render(
db_conn: sqlite3.Connection,
user: User,
device_id: str,
app: App,
) -> bool:
"""Render an app if it's time to do so."""
if not app.path:
return False
app_path = Path(app.path)
app_basename = f"{app.name}-{app.iname}"
webp_device_path = db.get_device_webp_dir(device_id)
webp_device_path.mkdir(parents=True, exist_ok=True)
webp_path = webp_device_path / f"{app_basename}.webp"
if app_path.suffix.lower() == ".webp":
logger.debug(f"{app_basename} is a WebP app -- NO RENDER")
if not webp_path.exists():
# If the file doesn't exist in the device directory, copy it from the source.
# This can happen if the app was added before this logic was in place,
# or if the device's webp dir was cleared.
if app_path.exists():
shutil.copy(app_path, webp_path)
else:
logger.error(
f"Source WebP file not found for app {app_basename} at {app_path}"
)
return False
return webp_path.exists()
if app.pushed:
logger.debug("Pushed App -- NO RENDER")
return True
now = int(time.time())
if now - app.last_render > app.uinterval * 60:
logger.info(f"{app_basename} -- RENDERING")
device = user.devices[device_id]
config = app.config.copy()
add_default_config(config, device)
start_time = time.monotonic()
image = render_app(db_conn, app_path, config, webp_path, device, app, user)
end_time = time.monotonic()
render_duration = timedelta(seconds=end_time - start_time)
if image is None:
logger.error(f"Error rendering {app_basename}")
app.empty_last_render = not image
# set the devices pinned_app if autopin is true.
if app.autopin and image:
device.pinned_app = app.iname
app.last_render = now
app.last_render_duration = render_duration
device.apps[app.iname] = app
# Use granular field updates to avoid overwriting concurrent changes from web interface
try:
with db.db_transaction(db_conn) as cursor:
db.update_app_field(
cursor, user.username, device_id, app.iname, "last_render", now
)
db.update_app_field(
cursor,
user.username,
device_id,
app.iname,
"last_render_duration",
_format_timedelta_iso8601(render_duration),
)
db.update_app_field(
cursor,
user.username,
device_id,
app.iname,
"empty_last_render",
app.empty_last_render,
)
if app.autopin and image:
db.update_device_field(
cursor, user.username, device_id, "pinned_app", app.iname
)
except Exception as e:
logger.error(f"Failed to update app fields for {app_basename}: {e}")
return image is not None
logger.info(f"{app_basename} -- NO RENDER")
return True
def _format_timedelta_iso8601(td: timedelta) -> str:
"""Format a timedelta object to an ISO 8601 duration string (e.g., 'PT10.5S')."""
seconds = td.total_seconds()
return f"PT{seconds:g}S"
def send_default_image(device: Device) -> Response:
"""Send the default image."""
return send_image(MODULE_ROOT / "static" / "images" / "default.webp", device, None)
def send_image(
webp_path: Path,
device: Device,
app: App | None,
immediate: bool = False,
brightness: int | None = None,
dwell_secs: int | None = None,
stat_result: os.stat_result | None = None,
) -> Response:
"""Send an image as a response."""
if immediate:
with webp_path.open("rb") as f:
response = Response(content=f.read(), media_type="image/webp")
else:
response = FileResponse(
webp_path, media_type="image/webp", stat_result=stat_result
)
# Use provided brightness or calculate it
b = brightness or db.get_device_brightness_percent(device)
# Use provided dwell_secs or calculate it
if dwell_secs is not None:
s = dwell_secs
else:
device_interval = device.default_interval or 5
s = app.display_time if app and app.display_time > 0 else device_interval
response.headers["Cache-Control"] = "public, max-age=0, must-revalidate"
response.headers["Tronbyt-Brightness"] = str(b)
response.headers["Tronbyt-Dwell-Secs"] = str(s)
if immediate:
response.headers["Tronbyt-Immediate"] = "1"
return response
async def push_image(
device_id: str,
installation_id: str | None,
image_bytes: bytes,
db_conn: sqlite3.Connection,
) -> None:
"""Save a pushed image and notify the device."""
device = db.get_device_by_id(db_conn, device_id)
if device and device.info.protocol_type == ProtocolType.WS:
get_sync_manager().notify(device_id, SyncPayload(payload=image_bytes))
# If it's a permanent installation, we still need to write to disk
if installation_id:
db.add_pushed_app(db_conn, device_id, installation_id)
device_webp_path = db.get_device_webp_dir(device_id)
pushed_path = device_webp_path / "pushed"
pushed_path.mkdir(exist_ok=True)
filename = f"{secure_filename(installation_id)}.webp"
file_path = pushed_path / filename
file_path.write_bytes(image_bytes)
return
# Fallback to file-based push for non-websocket or unknown devices
device_webp_path = db.get_device_webp_dir(device_id)
pushed_path = device_webp_path / "pushed"
pushed_path.mkdir(exist_ok=True)
if installation_id:
filename = f"{secure_filename(installation_id)}.webp"
else:
filename = f"__{time.monotonic_ns()}.webp"
file_path = pushed_path / filename
file_path.write_bytes(image_bytes)
if installation_id and db_conn:
db.add_pushed_app(db_conn, device_id, installation_id)
# No notification for non-websocket devices, as it's not needed.

View File

@@ -1,125 +0,0 @@
"""Version information utilities for tronbyt-server."""
import json
import logging
from pathlib import Path
import requests
from packaging import version as pkg_version
from pydantic import BaseModel, ConfigDict, ValidationError, field_validator
logger = logging.getLogger(__name__)
class VersionInfo(BaseModel):
"""Typed model for version information."""
version: str = "dev"
commit_hash: str | None = None
tag: str | None = None
branch: str | None = None
build_date: str | None = None
# Ignore extra fields if present in the JSON file
model_config = ConfigDict(extra="ignore")
@field_validator("version")
@classmethod
def version_must_not_be_empty(cls, v: str) -> str:
"""Ensure version is not an empty string."""
return v or "dev"
def get_version_info() -> VersionInfo:
"""Get version information from version.json file.
Returns:
VersionInfo instance populated from version.json or with defaults.
"""
default_info = VersionInfo()
try:
# Look for version.json in the same directory as this module
version_file = Path(__file__).parent / "version.json"
if not version_file.exists():
logger.warning(
f"Version file not found at {version_file}, using default version '{default_info.version}'"
)
return default_info
with version_file.open("r") as f:
version_data = json.load(f)
try:
result = VersionInfo.model_validate(version_data)
except ValidationError as e:
logger.error(
f"Version data validation failed: {e}, using default version '{default_info.version}'"
)
return default_info
logger.debug(f"Loaded version info: {result.model_dump()}")
return result
except (json.JSONDecodeError, IOError, KeyError) as e:
logger.warning(
f"Failed to read version file: {e}, using default version '{default_info.version}'"
)
return default_info
def get_short_commit_hash() -> str | None:
"""Get the short commit hash (first 7 characters).
Returns:
Short commit hash string or None if not available.
"""
commit_hash = get_version_info().commit_hash
if commit_hash:
return commit_hash[:7]
return None
def check_for_updates(version_info: VersionInfo) -> tuple[bool, str | None]:
"""Check for updates on GitHub.
Args:
version_info: The current version info object.
Returns:
Tuple of (update_available, latest_release_url).
"""
if not version_info.tag:
return False, None
try:
response = requests.get(
"https://api.github.com/repos/tronbyt/server/releases/latest",
timeout=2.0,
)
response.raise_for_status()
data = response.json()
latest_tag = data.get("tag_name")
html_url = data.get("html_url")
if not latest_tag or not html_url:
logger.warning("Incomplete update data from GitHub API.")
return False, None
# Remove 'v' prefix if present for comparison
clean_latest = latest_tag.lstrip("v")
clean_current = version_info.tag.lstrip("v")
if pkg_version.parse(clean_latest) > pkg_version.parse(clean_current):
return True, html_url
except (
requests.exceptions.RequestException,
ValueError,
pkg_version.InvalidVersion,
) as e:
logger.warning(f"Failed to check for updates: {e}")
return False, None