mirror of
https://github.com/tronbyt/server.git
synced 2025-12-19 08:25:46 +01:00
chore: delete the Python code after migration to Go
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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!
|
||||
@@ -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 📌)
|
||||
- **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;">
|
||||
📌 {{ _('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 📌 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!
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
"""Tronbyt Server application."""
|
||||
@@ -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()
|
||||
1362
tronbyt_server/db.py
1362
tronbyt_server/db.py
File diff suppressed because it is too large
Load Diff
@@ -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"))
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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 = ""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
"""Routers for the application."""
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
6
tronbyt_server/static/css/brands.min.css
vendored
6
tronbyt_server/static/css/brands.min.css
vendored
File diff suppressed because one or more lines are too long
@@ -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 */
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
6
tronbyt_server/static/css/solid.min.css
vendored
6
tronbyt_server/static/css/solid.min.css
vendored
@@ -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}
|
||||
@@ -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 */
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
})();
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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()))
|
||||
@@ -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)
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">×</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>
|
||||
@@ -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 %}
|
||||
@@ -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 }}  ({{ _('installation id') }}) </h1>
|
||||
<h1>{% if app.enabled %}<enabled>  -- {{ _('Enabled') }} --</enabled>{% else %}
|
||||
<disabled>  -- {{ _('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
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user