mirror of
https://github.com/i12bp8/ESLPwn.git
synced 2026-09-16 16:42:37 +00:00
Finalize V2.0 Stability: Fixed small tag timeout and IR jitter
This commit is contained in:
+3
-6
@@ -10,12 +10,9 @@ build/
|
||||
# ufbt
|
||||
.ufbt/
|
||||
|
||||
# Android companion
|
||||
android-companion/.gradle/
|
||||
android-companion/build/
|
||||
android-companion/app/build/
|
||||
android-companion/local.properties
|
||||
android-companion/release-artifacts/
|
||||
# AI Assistants
|
||||
.gemini/
|
||||
.codex/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
+10
-30
@@ -1,57 +1,37 @@
|
||||
# Contributing to TagTinker
|
||||
|
||||
Thanks for wanting to help out. Here's how to get involved.
|
||||
Thanks for wanting to help out! Here's how to get involved.
|
||||
|
||||
## Getting started
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repo
|
||||
2. Create a feature branch: `git checkout -b my-feature`
|
||||
3. Make your changes
|
||||
4. Test on owned lab hardware if possible (or at minimum, ensure `ufbt build` passes)
|
||||
4. Test on hardware if possible (or at minimum, ensure `ufbt build` passes)
|
||||
5. Commit with a clear message
|
||||
6. Open a pull request
|
||||
|
||||
## Code style
|
||||
## Code Style
|
||||
|
||||
- C99, no C++ features
|
||||
- 4-space indentation (no tabs)
|
||||
- `snake_case` for functions and variables
|
||||
- Keep functions short and focused
|
||||
- Comment non-obvious logic, but don't over-comment
|
||||
|
||||
## What we're looking for
|
||||
## What We're Looking For
|
||||
|
||||
- Bug fixes (especially memory-related — the Flipper has very limited heap)
|
||||
- New ESL tag compatibility (different sizes and display variants)
|
||||
- Better compression or transmission reliability
|
||||
- Support for new ESL tag sizes and models
|
||||
- UI/UX improvements
|
||||
- Documentation improvements
|
||||
- Android companion app improvements
|
||||
|
||||
## Project boundaries
|
||||
## Reporting Issues
|
||||
|
||||
- Keep contributions aligned with educational use on ESL hardware the operator owns.
|
||||
- Do not add documentation aimed at store use, retail deployment, or third-party systems.
|
||||
- Prefer generic ESL wording over store- or brand-specific framing in user-facing docs.
|
||||
- Preserve credit to furrtek's reverse-engineering research where relevant.
|
||||
|
||||
## What to avoid
|
||||
|
||||
- Don't break existing functionality without good reason
|
||||
- Don't add external dependencies — this runs on a microcontroller with strict constraints
|
||||
- Don't submit AI-generated code without reviewing and testing it
|
||||
- Keep memory usage minimal — every byte counts on the Flipper Zero
|
||||
|
||||
## Reporting issues
|
||||
|
||||
Open a GitHub issue with:
|
||||
Please open a GitHub issue with:
|
||||
- What you expected to happen
|
||||
- What actually happened
|
||||
- Your Flipper firmware version
|
||||
- The ESL model you're using (if applicable)
|
||||
|
||||
## Hardware safety
|
||||
|
||||
Be careful with repeated refresh or LED test loops on your own tags. Power and display behavior vary by model, and long-running tests can have side effects.
|
||||
- The ESL model you're testing with (if applicable)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
## Legality
|
||||
|
||||
This repository is a clean open-source port of publicly documented reverse-engineering research into an infrared communication protocol. The original analysis (frame structure, pulse-position modulation, timing, commands, and CRC) has been openly available on GitHub for over 7 years with no known legal challenges, takedowns, or cease-and-desist notices worldwide.
|
||||
|
||||
### How the protocol works (high-level)
|
||||
The system transmits data using line-of-sight infrared signals at 940 nm with a 1.25 MHz carrier, encoded via pulse-position modulation (PP4 or PP16). Frames include a version field, 32-bit tag identifier (derived from a standard barcode), command byte, optional payload (e.g. image or segment data), a weak 16-bit key (frequently default 0x0000), and a CRC-16 checksum. No strong encryption or technological protection measures control access to the signaling. The behavior is observable and reproducible with compatible hardware when the tags are in the possession of the user.
|
||||
|
||||
The project implements independent transmission logic for research and interoperability purposes only.
|
||||
|
||||
### Why publishing this code is generally considered lawful research activity
|
||||
- **Copyright law does not protect ideas, principles, protocols, or functional behavior.**
|
||||
Copyright protects specific expression (source code), not the underlying functionality or observed communication formats. Documenting and independently re-implementing observable signal behavior from lawfully acquired hardware does not infringe copyright in most jurisdictions.
|
||||
|
||||
- **European Union law supports observation and study of products.**
|
||||
Under the EU Trade Secrets Directive (2016/943), Article 3, the acquisition of information by "observation, study, disassembly or testing of a product ... that is lawfully in the possession of the acquirer" is considered a **lawful means**, provided there is no contractual obligation to the contrary.
|
||||
The Software Directive (2009/24/EC), Article 5(3), further allows legitimate users to observe, study or test the functioning of a program/device to determine underlying ideas and principles.
|
||||
|
||||
- **United States and similar jurisdictions.**
|
||||
Reverse engineering by fair and honest means (analyzing publicly observable signals from devices you lawfully possess) is widely recognized as legitimate research and does not generally constitute misappropriation of trade secrets or copyright infringement. The DMCA §1201(f) provides an additional interoperability exception for circumvention of access controls, though no such controlled access is present in this raw signaling protocol.
|
||||
|
||||
- **Practical precedent.**
|
||||
Comparable open-source projects involving protocol analysis, IR tools, sub-GHz decoders, and RFID research have existed publicly for years on platforms like the Flipper Zero with minimal enforcement action when proper boundaries are maintained.
|
||||
|
||||
### Important boundaries and responsible use
|
||||
This project is released **strictly for educational, research, and interoperability purposes** under the GPL-3.0 license.
|
||||
|
||||
- Use is permitted only with tags and devices you personally own or have explicit permission to test.
|
||||
- The repository contains source code only (no pre-built binaries) and includes explicit warnings against misuse.
|
||||
- Any unauthorized use on third-party systems (such as modifying displays in a retail environment without authorization) may violate laws on fraud, interference with business operations, computer misuse, or similar provisions in your jurisdiction. Such actions are the sole responsibility of the user and are not authorized or endorsed by this project.
|
||||
|
||||
Because the repository is public and intended for a worldwide audience, users must ensure compliance with the laws applicable in their own country.
|
||||
|
||||
This document is not legal advice. Reverse engineering and protocol research exist in a complex area of law that can vary by jurisdiction and specific facts. If in doubt, consult a qualified lawyer in your country.
|
||||
|
||||
For reference:
|
||||
- EU Trade Secrets Directive 2016/943 (Article 3)
|
||||
- EU Software Directive 2009/24/EC (Article 5(3))
|
||||
- US DMCA §1201(f) (interoperability exception)
|
||||
@@ -1,157 +1,67 @@
|
||||
# TagTinker
|
||||
# TagTinker V2.0
|
||||
|
||||
<p align="center">
|
||||
<strong>Infrared ESL Research Toolkit for Flipper Zero</strong><br>
|
||||
<sub>Protocol study • Signal analysis • Controlled display experiments on authorized hardware</sub>
|
||||
<sub>Protocol study • Signal analysis • Digital Art</sub>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="License: GPL-3.0" src="https://img.shields.io/badge/License-GPL--3.0-blue.svg">
|
||||
<img alt="Platform: Flipper Zero" src="https://img.shields.io/badge/Platform-Flipper%20Zero-black.svg">
|
||||
<img alt="Status: Research Project" src="https://img.shields.io/badge/Status-Research%20Only-informational.svg">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/i12bp8/TagTinker/blob/main/demo.jpg" width="700" alt="TagTinker demo">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub>Owner-authorized lab display experiment</sub>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **TagTinker is a research tool.**
|
||||
>
|
||||
> It is intended **only** for protocol study, signal analysis, and controlled experiments on hardware you personally own or are explicitly authorized to test.
|
||||
>
|
||||
> This repository does **not** authorize access to, modification of, or interference with any third-party deployment, commercial installation, or retail environment.
|
||||
|
||||
> [!WARNING]
|
||||
> **Strictly prohibited uses include:**
|
||||
> - Testing against deployed third-party systems
|
||||
> - Use in retail or commercial environments
|
||||
> - Altering prices, product data, or operational displays
|
||||
> - Interfering with business operations
|
||||
> - Bypassing pairing, authorization, or security controls
|
||||
> - Any unauthorized, unlawful, or harmful activity
|
||||
|
||||
## Overview
|
||||
|
||||
TagTinker is a Flipper Zero app for **educational research into infrared electronic shelf-label protocols** and related display behavior on authorized test hardware.
|
||||
TagTinker is a Flipper Zero app and Android Companion for exploring infrared electronic shelf-label (ESL) protocols. It allows you to transmit custom images and text to supported graphics tags.
|
||||
|
||||
It is focused on:
|
||||
- protocol observation and replay analysis
|
||||
- controlled display experiments
|
||||
- monochrome image preparation workflows
|
||||
- local tooling for research and interoperability testing
|
||||
As the Flipper Zero team notes:
|
||||
> "FYI: this is pure infrared signal, same that you use in TV remotes. The whole security was relying on obscurity of protocol."
|
||||
|
||||
This README intentionally avoids deployment-oriented instructions and excludes guidance for interacting with live commercial systems.
|
||||
This tool is built for IoT security curiosity, learning about obscure protocols, and displaying digital art on e-ink hardware.
|
||||
|
||||
> [!WARNING]
|
||||
> **Hardware Warning:** Many infrared ESL tags store their firmware, address, and display data in volatile RAM to save cost and energy. If you remove the battery or let it fully discharge, the tag will lose all programming and become unresponsive ("dead"). It usually cannot be recovered without the original base station.
|
||||
|
||||
## Features
|
||||
|
||||
- Text, image, and test-pattern display experiments
|
||||
- Local web-based image preparation utility (`tools/tagtinker.html`)
|
||||
- Signal and response testing for authorized bench hardware
|
||||
- Small, modular codebase suitable for further research
|
||||
- Research-first project structure with clear scope boundaries
|
||||
- **TagTinker Flipper App:** High-performance, zero-allocation RLE streaming IR engine.
|
||||
- **TagTinker Android Companion:** Edit and dither images directly on your phone and sync them instantly to the Flipper Zero over BLE.
|
||||
- Display text, custom images, and test-patterns.
|
||||
- Support for monochrome and accent-color (red/yellow) graphics tags.
|
||||
|
||||
## Getting Started
|
||||
|
||||
The TagTinker Android app manages preparing display payloads and uploading them to the Flipper over BLE.
|
||||
1. Build the Flipper app from this repository and install it via `ufbt`.
|
||||
2. Download the pre-built Android Companion APK from the [Releases](https://github.com/i12bp8/TagTinker/releases) page and install it on your device.
|
||||
3. Open the Flipper app and go to **Phone Sync (Custom Images)**.
|
||||
4. Use the Android app to connect, prepare an image, and send it directly to the Flipper for transmission.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Does this require a Flipper Zero?**
|
||||
|
||||
No, not at all! You can do this with less than $5 worth of microcontroller hardware (like an ESP32 and an IR LED). The Flipper Zero just happens to be my favorite security research tool, which is why I built the app for this platform.
|
||||
|
||||
**Where is the `.fap` release?**
|
||||
|
||||
The Flipper app is source-first. Build the `.fap` yourself from this repository with `ufbt` so it matches your firmware and local toolchain.
|
||||
|
||||
**What if it crashes or behaves oddly?**
|
||||
|
||||
The maintainer primarily uses TagTinker on Momentum firmware with asset packs disabled and has not had issues in that setup. If you are using a different firmware branch, custom asset packs, or a heavily modified device setup, start by testing from a clean baseline.
|
||||
If you are using a custom firmware branch, custom asset packs, or a heavily modified device setup, start by testing from a clean baseline firmware.
|
||||
|
||||
**What happens if I pull the battery out of the tag?**
|
||||
## Credits & Background
|
||||
|
||||
Many infrared ESL tags store their firmware, address, and display data in volatile RAM (not flash memory) to save cost and energy.
|
||||
If you remove the battery or let it fully discharge, the tag will lose all programming and become **unresponsive ("dead")**. It usually cannot be recovered without the original base station.
|
||||
|
||||
**I found a bug or want to contribute — how can I get in touch?**
|
||||
|
||||
You can contact me on:
|
||||
- Discord: **@i12bp8**
|
||||
- Telegram: **@i12bp8**
|
||||
|
||||
I'm currently traveling, so response times may be slower than usual. Feel free to open issues or Pull Requests anyway — contributions (bug fixes, improvements, documentation, etc.) are very welcome and will help keep the project alive while I'm away.
|
||||
|
||||
|
||||
|
||||
## How It Works
|
||||
|
||||
TagTinker is built around the study of **infrared electronic shelf-label communication** used by fixed-transmitter labeling systems.
|
||||
|
||||
At a high level:
|
||||
|
||||
- tags receive modulated infrared transmissions rather than ordinary consumer-IR commands
|
||||
- communication is based on addressed protocol frames containing command, parameter, and integrity fields
|
||||
- display updates are carried as prepared payloads for supported monochrome graphics formats
|
||||
- local tooling in this project helps researchers prepare assets and perform controlled experiments on authorized hardware
|
||||
|
||||
This project is intended to help researchers understand:
|
||||
- signal structure
|
||||
- frame and payload behavior
|
||||
- display data preparation constraints
|
||||
- safe, authorized bench-testing workflows
|
||||
|
||||
For the underlying reverse-engineering background and deeper protocol research, see:
|
||||
This project is deeply indebted to the incredible public reverse-engineering work by **furrtek**.
|
||||
To understand the underlying protocol, signal structure, and history, please read his research:
|
||||
- **Furrtek’s ESL research:** [https://www.furrtek.org/?a=esl](https://www.furrtek.org/?a=esl)
|
||||
- **PrecIR reference implementation:** [https://github.com/furrtek/PrecIR](https://github.com/furrtek/PrecIR)
|
||||
|
||||
## Project Scope
|
||||
## Disclaimer
|
||||
|
||||
TagTinker is limited to **home-lab and authorized research use**, including:
|
||||
|
||||
- infrared protocol study
|
||||
- signal timing and frame analysis
|
||||
- controlled experiments on owned or authorized hardware
|
||||
- monochrome asset preparation for testing
|
||||
- educational diagnostics and interoperability research
|
||||
|
||||
It is **not** a retail tool, operational tool, or field-use utility.
|
||||
|
||||
## Responsible Use
|
||||
|
||||
You are solely responsible for ensuring that any use of this software is lawful, authorized, and appropriate for your environment.
|
||||
|
||||
The maintainer does not authorize, approve, or participate in any unauthorized use of this project, and disclaims responsibility for misuse, damage, disruption, legal violations, or any consequences arising from such use.
|
||||
|
||||
If you do not own the hardware, or do not have explicit written permission to test it, **do not use this project on it**.
|
||||
|
||||
Any unauthorized use is outside the intended scope of this repository and is undertaken entirely at the user’s own risk.
|
||||
|
||||
## No Affiliation
|
||||
|
||||
This is an **independent research project**.
|
||||
|
||||
It is not affiliated with, endorsed by, authorized by, or sponsored by any electronic shelf-label vendor, retailer, infrastructure provider, or system operator.
|
||||
|
||||
Any references to external research, public documentation, or reverse-engineering work are included strictly for educational and research context.
|
||||
|
||||
## Credits
|
||||
|
||||
This project is a port and adaptation of the excellent public reverse-engineering work by **[furrtek / PrecIR](https://github.com/furrtek/PrecIR)** and related community research.
|
||||
TagTinker is an independent project intended for educational research, security curiosity, and digital art on hardware you own. I do not condone using this software for illegal purposes, altering retail displays, or interfering with third-party infrastructure. Please use it responsibly.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **GNU General Public License v3.0** (GPL-3.0).
|
||||
See the [`LICENSE`](LICENSE) file for details.
|
||||
|
||||
## Warranty Disclaimer
|
||||
|
||||
This software is provided **“AS IS”**, without warranty of any kind, express or implied.
|
||||
|
||||
In no event shall the authors or copyright holders be liable for any claim, damages, or other liability arising from the use of this software.
|
||||
|
||||
## Maintainer Statement
|
||||
|
||||
This repository is maintained as a **narrowly scoped educational research project**.
|
||||
|
||||
The maintainer does **not** authorize, encourage, condone, or accept responsibility for use against third-party devices, deployed commercial systems, retail infrastructure, or any environment where the user lacks explicit permission.
|
||||
|
||||
**Research responsibly.**
|
||||
Licensed under the **GNU General Public License v3.0** (GPL-3.0). See the [`LICENSE`](LICENSE) file for details.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
#Thu Apr 23 20:33:11 CEST 2026
|
||||
gradle.version=8.11.1
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
kotlin version: 2.0.21
|
||||
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||
1. Kotlin compile daemon is ready
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
kotlin version: 2.0.21
|
||||
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
|
||||
1. Kotlin compile daemon is ready
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.i12bp8.tagtinker.companion"
|
||||
namespace = "xyz.i12bp8.tagtinker.android"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.i12bp8.tagtinker.companion"
|
||||
minSdk = 26
|
||||
applicationId = "xyz.i12bp8.tagtinker.android"
|
||||
minSdk = 29
|
||||
targetSdk = 35
|
||||
versionCode = 13
|
||||
versionName = "1.3.0"
|
||||
versionCode = 1
|
||||
versionName = "2.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,26 +33,53 @@ android {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.activity:activity-compose:1.9.2")
|
||||
implementation("androidx.fragment:fragment-ktx:1.8.2")
|
||||
implementation(platform("androidx.compose:compose-bom:2024.09.02"))
|
||||
val composeBom = platform("androidx.compose:compose-bom:2025.04.01")
|
||||
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.16.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
implementation("androidx.activity:activity-compose:1.10.1")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.9")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3:1.3.2")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.camera:camera-core:1.4.2")
|
||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||
implementation("androidx.camera:camera-lifecycle:1.4.2")
|
||||
implementation("androidx.camera:camera-view:1.4.2")
|
||||
implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("no.nordicsemi.android.kotlin.ble:scanner:1.3.1")
|
||||
implementation("no.nordicsemi.android.kotlin.ble:client:1.3.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.2.1")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
|
||||
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,2 +1 @@
|
||||
# no custom rules
|
||||
# Intentionally minimal for first prototype build.
|
||||
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth_le"
|
||||
android:required="true" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.companion_device_setup"
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="TagTinker Badge Creator"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
|
||||
android:label="TagTinker"
|
||||
android:theme="@style/Theme.TagTinker">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
||||
package xyz.i12bp8.tagtinker.android
|
||||
|
||||
import android.app.Application
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import xyz.i12bp8.tagtinker.android.ble.FlipperBleClient
|
||||
import xyz.i12bp8.tagtinker.android.data.HistoryStore
|
||||
import xyz.i12bp8.tagtinker.android.data.isValidBarcode
|
||||
import xyz.i12bp8.tagtinker.android.data.normalizeBarcode
|
||||
import xyz.i12bp8.tagtinker.android.data.tagFromBarcode
|
||||
import xyz.i12bp8.tagtinker.android.model.BlePeripheral
|
||||
import xyz.i12bp8.tagtinker.android.model.EditorState
|
||||
import xyz.i12bp8.tagtinker.android.model.PayloadHistoryItem
|
||||
import xyz.i12bp8.tagtinker.android.model.TagRecord
|
||||
import xyz.i12bp8.tagtinker.android.model.UploadJob
|
||||
import xyz.i12bp8.tagtinker.android.model.UploadProgress
|
||||
import xyz.i12bp8.tagtinker.android.render.ImagePipeline
|
||||
|
||||
data class MainUiState(
|
||||
val status: String = "Disconnected",
|
||||
val connected: Boolean = false,
|
||||
val scanning: Boolean = false,
|
||||
val devices: List<BlePeripheral> = emptyList(),
|
||||
val tag: TagRecord? = null,
|
||||
val editor: EditorState = EditorState(),
|
||||
val sourceBitmap: Bitmap? = null,
|
||||
val previewBitmap: Bitmap? = null,
|
||||
val payloadSizeLabel: String = "No image",
|
||||
val progress: Float = 0f,
|
||||
val logs: List<String> = emptyList(),
|
||||
val history: List<PayloadHistoryItem> = emptyList(),
|
||||
val manualBarcode: String = "",
|
||||
)
|
||||
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val bleClient = FlipperBleClient(application)
|
||||
private val historyStore = HistoryStore(application)
|
||||
private val _uiState = MutableStateFlow(MainUiState(history = historyStore.load()))
|
||||
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
|
||||
private var renderJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
bleClient.scanResults.collect { results ->
|
||||
_uiState.value = _uiState.value.copy(devices = results)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
bleClient.scanStatus.collect { status ->
|
||||
if (!status.isNullOrBlank() && !_uiState.value.connected) {
|
||||
_uiState.value = _uiState.value.copy(status = status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startScan() {
|
||||
bleClient.startScan()
|
||||
_uiState.value = _uiState.value.copy(scanning = true, status = "Scanning nearby Flippers")
|
||||
log("Scanning")
|
||||
}
|
||||
|
||||
fun stopScan() {
|
||||
bleClient.stopScan()
|
||||
_uiState.value = _uiState.value.copy(scanning = false)
|
||||
}
|
||||
|
||||
fun setStatus(status: String) {
|
||||
_uiState.value = _uiState.value.copy(status = status)
|
||||
log(status)
|
||||
}
|
||||
|
||||
fun connect(address: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
bleClient.connect(address)
|
||||
}.onSuccess {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
connected = true,
|
||||
scanning = false,
|
||||
status = "BLE ready",
|
||||
)
|
||||
log("BLE ready")
|
||||
}.onFailure {
|
||||
_uiState.value = _uiState.value.copy(status = it.message ?: "BLE failed")
|
||||
log(it.message ?: "BLE failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
viewModelScope.launch {
|
||||
bleClient.disconnect()
|
||||
_uiState.value = MainUiState(history = historyStore.load())
|
||||
}
|
||||
}
|
||||
|
||||
fun onManualBarcodeChanged(value: String) {
|
||||
_uiState.value = _uiState.value.copy(manualBarcode = value)
|
||||
}
|
||||
|
||||
fun acceptBarcode(raw: String) {
|
||||
val barcode = normalizeBarcode(raw)
|
||||
if (!isValidBarcode(barcode)) {
|
||||
log("Invalid barcode")
|
||||
return
|
||||
}
|
||||
_uiState.value = _uiState.value.copy(
|
||||
tag = tagFromBarcode(barcode),
|
||||
manualBarcode = barcode,
|
||||
)
|
||||
renderPreview()
|
||||
log("Loaded tag $barcode")
|
||||
}
|
||||
|
||||
fun loadImage(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
getApplication<Application>().contentResolver.openInputStream(uri)?.use { input ->
|
||||
BitmapFactory.decodeStream(input)
|
||||
}
|
||||
} ?: return@launch
|
||||
_uiState.value = _uiState.value.copy(sourceBitmap = bitmap)
|
||||
renderPreview()
|
||||
log("Loaded image")
|
||||
}
|
||||
}
|
||||
|
||||
fun updateEditor(transform: EditorState) {
|
||||
_uiState.value = _uiState.value.copy(editor = transform)
|
||||
scheduleRenderPreview()
|
||||
}
|
||||
|
||||
fun updateEditor(mutator: (EditorState) -> EditorState) {
|
||||
_uiState.value = _uiState.value.copy(editor = mutator(_uiState.value.editor))
|
||||
scheduleRenderPreview()
|
||||
}
|
||||
|
||||
private fun scheduleRenderPreview() {
|
||||
renderJob?.cancel()
|
||||
renderJob = viewModelScope.launch {
|
||||
delay(72)
|
||||
renderPreview()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderPreview() {
|
||||
val tag = _uiState.value.tag ?: return
|
||||
val source = _uiState.value.sourceBitmap ?: run {
|
||||
_uiState.value = _uiState.value.copy(previewBitmap = null, payloadSizeLabel = "No image")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val result = ImagePipeline.render(
|
||||
source = source,
|
||||
width = tag.width,
|
||||
height = tag.height,
|
||||
accentColor = tag.color,
|
||||
editor = _uiState.value.editor,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
val cacheFile = File(getApplication<Application>().cacheDir, "latest_payload.bmp")
|
||||
cacheFile.writeBytes(result.bmpBytes)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
previewBitmap = result.bitmap,
|
||||
payloadSizeLabel = "${result.bmpBytes.size} B",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendCurrentPayload() {
|
||||
val state = _uiState.value
|
||||
val tag = state.tag ?: return
|
||||
val bmpFile = File(getApplication<Application>().cacheDir, "latest_payload.bmp")
|
||||
if (!bmpFile.exists()) {
|
||||
log("No payload rendered")
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
progress = 0f,
|
||||
status = "Uploading to Flipper",
|
||||
)
|
||||
runCatching {
|
||||
bleClient.upload(
|
||||
UploadJob(
|
||||
jobId = UUID.randomUUID().toString().replace("-", "").take(6).uppercase(),
|
||||
barcode = tag.barcode,
|
||||
width = tag.width,
|
||||
height = tag.height,
|
||||
page = (state.editor.page - 1).coerceAtLeast(0),
|
||||
bmpBytes = bmpFile.readBytes(),
|
||||
),
|
||||
) { progress ->
|
||||
applyProgress(progress)
|
||||
}
|
||||
}.onSuccess {
|
||||
val stamped = File(
|
||||
getApplication<Application>().filesDir,
|
||||
"payload-${System.currentTimeMillis()}.bmp",
|
||||
)
|
||||
bmpFile.copyTo(stamped, overwrite = true)
|
||||
val updated = listOf(
|
||||
PayloadHistoryItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
barcode = tag.barcode,
|
||||
name = tag.name,
|
||||
width = tag.width,
|
||||
height = tag.height,
|
||||
color = tag.color,
|
||||
page = state.editor.page,
|
||||
bmpPath = stamped.absolutePath,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
),
|
||||
) + _uiState.value.history
|
||||
historyStore.save(updated)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
history = updated.take(24),
|
||||
progress = 1f,
|
||||
status = "Saved on Flipper",
|
||||
)
|
||||
log("Upload complete")
|
||||
}.onFailure {
|
||||
_uiState.value = _uiState.value.copy(status = it.message ?: "Upload failed")
|
||||
log(it.message ?: "Upload failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resendHistory(item: PayloadHistoryItem) {
|
||||
acceptBarcode(item.barcode)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
editor = _uiState.value.editor.copy(page = item.page),
|
||||
sourceBitmap = BitmapFactory.decodeFile(item.bmpPath),
|
||||
)
|
||||
renderPreview()
|
||||
}
|
||||
|
||||
private fun applyProgress(progress: UploadProgress) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
progress = if (progress.bytesTotal == 0) 0f else progress.bytesDone.toFloat() / progress.bytesTotal,
|
||||
status = progress.label,
|
||||
)
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
logs = (_uiState.value.logs + "${System.currentTimeMillis()}: $message").takeLast(24),
|
||||
)
|
||||
}
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
package xyz.i12bp8.tagtinker.android.ble
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import no.nordicsemi.android.kotlin.ble.client.main.callback.ClientBleGatt
|
||||
import no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattCharacteristic
|
||||
import no.nordicsemi.android.kotlin.ble.core.data.BleWriteType
|
||||
import no.nordicsemi.android.kotlin.ble.core.data.BleGattConnectOptions
|
||||
import no.nordicsemi.android.kotlin.ble.core.data.util.DataByteArray
|
||||
import no.nordicsemi.android.kotlin.ble.core.scanner.BleScanMode
|
||||
import no.nordicsemi.android.kotlin.ble.core.scanner.BleScannerSettings
|
||||
import no.nordicsemi.android.kotlin.ble.scanner.BleScanner
|
||||
import no.nordicsemi.android.kotlin.ble.scanner.aggregator.BleScanResultAggregator
|
||||
import xyz.i12bp8.tagtinker.android.model.BlePeripheral
|
||||
import xyz.i12bp8.tagtinker.android.model.UploadJob
|
||||
import xyz.i12bp8.tagtinker.android.model.UploadProgress
|
||||
|
||||
// Official Flipper Serial UUIDs
|
||||
private val serialServiceUuid = UUID.fromString("8fe5b3d5-2e7f-4a98-2a48-7acc60fe0000")
|
||||
private val serialWriteUuid = UUID.fromString("19ed82ae-ed21-4c9d-4145-228e62fe0000") // Phone -> Flipper
|
||||
private val serialNotifyUuid = UUID.fromString("19ed82ae-ed21-4c9d-4145-228e61fe0000") // Flipper -> Phone
|
||||
|
||||
private const val logTag = "TagTinkerBle"
|
||||
private const val connectTimeoutMs = 30000L
|
||||
private const val mtuSize = 247
|
||||
|
||||
// How many bytes per chunk we send to the Flipper.
|
||||
// 128 bytes → ~172 chars base64 → well under BLE MTU with header.
|
||||
private const val CHUNK_BYTES = 128
|
||||
|
||||
class FlipperBleClient(private val context: Context) {
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private val adapter: BluetoothAdapter? =
|
||||
context.getSystemService(BluetoothManager::class.java)?.adapter
|
||||
|
||||
private val _scanResults = MutableStateFlow<List<BlePeripheral>>(emptyList())
|
||||
val scanResults: StateFlow<List<BlePeripheral>> = _scanResults
|
||||
private val _scanStatus = MutableStateFlow<String?>(null)
|
||||
val scanStatus: StateFlow<String?> = _scanStatus
|
||||
|
||||
private var notifyJob: Job? = null
|
||||
private var client: ClientBleGatt? = null
|
||||
|
||||
private var writeCharacteristic: ClientBleGattCharacteristic? = null
|
||||
private var notifyCharacteristic: ClientBleGattCharacteristic? = null
|
||||
|
||||
private val ackWaiters = linkedMapOf<String, CompletableDeferred<String>>()
|
||||
private val rxBuffer = StringBuilder()
|
||||
|
||||
private var helloReceived = false
|
||||
private var pongReceived = false
|
||||
|
||||
fun isAvailable(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun startScan() { _scanStatus.value = "Use System Picker" }
|
||||
fun stopScan() {}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun connect(address: String) {
|
||||
Log.d(logTag, "Connecting to $address...")
|
||||
disconnect()
|
||||
|
||||
val bluetoothDevice = adapter?.getRemoteDevice(address) ?: error("Device not found")
|
||||
val serverDevice = no.nordicsemi.android.kotlin.ble.core.RealServerDevice(bluetoothDevice)
|
||||
|
||||
val gatt = withTimeout(connectTimeoutMs) {
|
||||
ClientBleGatt.connect(context, serverDevice, appScope, BleGattConnectOptions(autoConnect = false))
|
||||
}
|
||||
|
||||
if (!gatt.isConnected) error("Connection failed")
|
||||
this.client = gatt
|
||||
|
||||
Log.d(logTag, "GATT connected, waiting for bond...")
|
||||
try {
|
||||
withTimeout(15000) { gatt.waitForBonding() }
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Bonding stalled, continuing anyway")
|
||||
}
|
||||
|
||||
gatt.requestMtu(mtuSize)
|
||||
delay(500)
|
||||
|
||||
Log.d(logTag, "Discovering services...")
|
||||
gatt.discoverServices()
|
||||
|
||||
val services = withTimeout(20000) {
|
||||
var found: no.nordicsemi.android.kotlin.ble.client.main.service.ClientBleGattServices? = null
|
||||
repeat(3) { attempt ->
|
||||
if (attempt > 0) {
|
||||
Log.d(logTag, "Retrying service discovery (attempt ${attempt + 1})...")
|
||||
gatt.discoverServices()
|
||||
}
|
||||
found = withTimeoutOrNull(5000) {
|
||||
gatt.services.filter { it != null }.first()
|
||||
}
|
||||
if (found != null) return@withTimeout found
|
||||
delay(1000)
|
||||
}
|
||||
found
|
||||
} ?: error("BLE services unavailable")
|
||||
|
||||
val serialService = services.findService(serialServiceUuid) ?: error("Serial service missing")
|
||||
|
||||
notifyCharacteristic = serialService.findCharacteristic(serialNotifyUuid)
|
||||
writeCharacteristic = serialService.findCharacteristic(serialWriteUuid)
|
||||
|
||||
helloReceived = false
|
||||
pongReceived = false
|
||||
|
||||
val notifyProps = notifyCharacteristic?.properties
|
||||
Log.d(logTag, "Notify char props: $notifyProps")
|
||||
|
||||
notifyJob = appScope.launch {
|
||||
if (notifyProps?.contains(no.nordicsemi.android.kotlin.ble.core.data.BleGattProperty.PROPERTY_NOTIFY) == true ||
|
||||
notifyProps?.contains(no.nordicsemi.android.kotlin.ble.core.data.BleGattProperty.PROPERTY_INDICATE) == true) {
|
||||
notifyCharacteristic?.getNotifications(bufferOverflow = BufferOverflow.SUSPEND)?.collect {
|
||||
handleNotify(it.value)
|
||||
}
|
||||
} else {
|
||||
Log.e(logTag, "Notify characteristic has no NOTIFY or INDICATE property!")
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(logTag, "Subscribed to notifications, waiting for handshake...")
|
||||
sendHandshake()
|
||||
}
|
||||
|
||||
private suspend fun sendHandshake() {
|
||||
// Wait briefly to see if Flipper auto-sends TT_HELLO
|
||||
delay(3000)
|
||||
if (helloReceived) return
|
||||
|
||||
Log.d(logTag, "Flipper quiet — sending TT_PING poke...")
|
||||
repeat(3) { attempt ->
|
||||
runCatching {
|
||||
sendLine("TT_PING", ack = "TT_PONG")
|
||||
}.onSuccess {
|
||||
Log.d(logTag, "Handshake OK via PING")
|
||||
return
|
||||
}.onFailure {
|
||||
Log.w(logTag, "Handshake attempt ${attempt + 1} failed: ${it.message}")
|
||||
delay(1200)
|
||||
}
|
||||
}
|
||||
|
||||
if (!helloReceived && !pongReceived) {
|
||||
throw IllegalStateException("Handshake failed — Flipper not responding")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
notifyJob?.cancel()
|
||||
notifyJob = null
|
||||
runCatching { client?.disconnect() }
|
||||
runCatching { client?.close() }
|
||||
client = null
|
||||
ackWaiters.values.forEach { it.cancel() }
|
||||
ackWaiters.clear()
|
||||
rxBuffer.clear()
|
||||
}
|
||||
|
||||
private fun handleNotify(bytes: ByteArray) {
|
||||
if (bytes.isEmpty()) return
|
||||
Log.d(logTag, "RX Packet: ${bytes.joinToString("") { "%02X".format(it) }}")
|
||||
|
||||
String(bytes, Charsets.UTF_8).forEach { char ->
|
||||
if (char == '\n' || char == '\r') {
|
||||
val line = rxBuffer.toString().trim()
|
||||
rxBuffer.clear()
|
||||
if (line.isNotBlank()) {
|
||||
Log.d(logTag, "RX Line: $line")
|
||||
if (line == "TT_HELLO") helloReceived = true
|
||||
if (line == "TT_PONG") pongReceived = true
|
||||
ackWaiters.remove(line)?.complete(line)
|
||||
}
|
||||
} else if (char.code in 32..126) {
|
||||
rxBuffer.append(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a line to the Flipper and optionally waits for a specific ACK response.
|
||||
*
|
||||
* Flow control is entirely ACK-based: the phone sends one chunk, waits for
|
||||
* TT_ACK|N from the Flipper, then sends the next. There is no credit/window
|
||||
* mechanism — that approach was unreliable for large images.
|
||||
*
|
||||
* We retry up to [maxAttempts] times with [ackTimeoutMs] timeout each.
|
||||
*/
|
||||
suspend fun sendLine(
|
||||
line: String,
|
||||
ack: String? = null,
|
||||
ackTimeoutMs: Long = 8000L,
|
||||
maxAttempts: Int = 3,
|
||||
) {
|
||||
val char = writeCharacteristic ?: error("Not connected")
|
||||
val payload = "$line\n".encodeToByteArray()
|
||||
|
||||
repeat(maxAttempts) { attempt ->
|
||||
val deferred = ack?.let { CompletableDeferred<String>() }
|
||||
if (ack != null) ackWaiters[ack] = deferred!!
|
||||
|
||||
Log.d(logTag, "TX (attempt ${attempt + 1}/$maxAttempts): $line")
|
||||
char.write(DataByteArray(payload), BleWriteType.NO_RESPONSE)
|
||||
|
||||
if (ack == null) return
|
||||
|
||||
try {
|
||||
withTimeout(ackTimeoutMs) { deferred!!.await() }
|
||||
return // ACK received — success
|
||||
} catch (e: Exception) {
|
||||
Log.w(logTag, "Timeout waiting for '$ack' (attempt ${attempt + 1})")
|
||||
ackWaiters.remove(ack)
|
||||
if (attempt < maxAttempts - 1) delay(200) // brief pause before retry
|
||||
}
|
||||
}
|
||||
|
||||
error("Timed out after $maxAttempts attempts sending: $line")
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a BMP image to the Flipper via the TT_BEGIN / TT_DATA / TT_END protocol.
|
||||
*
|
||||
* Each chunk is sent and confirmed via ACK before the next chunk is sent.
|
||||
* Chunk size is [CHUNK_BYTES] bytes (128), encoded as URL-safe base64.
|
||||
*/
|
||||
suspend fun upload(job: UploadJob, onProgress: (UploadProgress) -> Unit) {
|
||||
onProgress(UploadProgress(0, job.bmpBytes.size, "Preparing..."))
|
||||
|
||||
// Verify connection is alive
|
||||
sendLine("TT_PING", ack = "TT_PONG")
|
||||
|
||||
// Begin upload — Flipper opens file and responds TT_ACK|BEGIN
|
||||
val beginCmd = "TT_BEGIN|${job.jobId}|${job.barcode}|${job.width}|${job.height}|${job.page}|${job.bmpBytes.size}"
|
||||
sendLine(beginCmd, ack = "TT_ACK|BEGIN", ackTimeoutMs = 10000L)
|
||||
|
||||
// Stream chunks — each chunk waits for its individual ACK
|
||||
var offset = 0
|
||||
var sequence = 1
|
||||
while (offset < job.bmpBytes.size) {
|
||||
val end = minOf(job.bmpBytes.size, offset + CHUNK_BYTES)
|
||||
val chunk = job.bmpBytes.copyOfRange(offset, end)
|
||||
val b64 = android.util.Base64.encodeToString(
|
||||
chunk,
|
||||
android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE
|
||||
)
|
||||
|
||||
onProgress(UploadProgress(offset, job.bmpBytes.size, "Chunk $sequence / ${(job.bmpBytes.size + CHUNK_BYTES - 1) / CHUNK_BYTES}"))
|
||||
sendLine("TT_DATA|$sequence|$b64", ack = "TT_ACK|$sequence", ackTimeoutMs = 10000L)
|
||||
|
||||
offset = end
|
||||
sequence++
|
||||
}
|
||||
|
||||
// Finalize — Flipper renames file and marks ready
|
||||
sendLine("TT_END|${job.jobId}", ack = "TT_ACK|END", ackTimeoutMs = 15000L)
|
||||
onProgress(UploadProgress(job.bmpBytes.size, job.bmpBytes.size, "Sync complete!"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package xyz.i12bp8.tagtinker.android.data
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import xyz.i12bp8.tagtinker.android.model.PayloadHistoryItem
|
||||
|
||||
class HistoryStore(context: Context) {
|
||||
private val prefs = context.getSharedPreferences("tagtinker_history", Context.MODE_PRIVATE)
|
||||
|
||||
fun load(): List<PayloadHistoryItem> {
|
||||
val raw = prefs.getString("items", "[]") ?: "[]"
|
||||
val json = JSONArray(raw)
|
||||
return buildList {
|
||||
for (index in 0 until json.length()) {
|
||||
val item = json.getJSONObject(index)
|
||||
add(
|
||||
PayloadHistoryItem(
|
||||
id = item.getString("id"),
|
||||
barcode = item.getString("barcode"),
|
||||
name = item.getString("name"),
|
||||
width = item.getInt("width"),
|
||||
height = item.getInt("height"),
|
||||
color = item.getString("color"),
|
||||
page = item.getInt("page"),
|
||||
bmpPath = item.getString("bmpPath"),
|
||||
timestamp = item.getLong("timestamp"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save(items: List<PayloadHistoryItem>) {
|
||||
val array = JSONArray()
|
||||
items.take(24).forEach { item ->
|
||||
array.put(
|
||||
JSONObject()
|
||||
.put("id", item.id)
|
||||
.put("barcode", item.barcode)
|
||||
.put("name", item.name)
|
||||
.put("width", item.width)
|
||||
.put("height", item.height)
|
||||
.put("color", item.color)
|
||||
.put("page", item.page)
|
||||
.put("bmpPath", item.bmpPath)
|
||||
.put("timestamp", item.timestamp),
|
||||
)
|
||||
}
|
||||
prefs.edit().putString("items", array.toString()).apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package xyz.i12bp8.tagtinker.android.data
|
||||
|
||||
import xyz.i12bp8.tagtinker.android.model.TagProfile
|
||||
import xyz.i12bp8.tagtinker.android.model.TagRecord
|
||||
|
||||
private val tagProfiles = listOf(
|
||||
TagProfile(1275, 320, 192, "graphic", "mono", "DM110"),
|
||||
TagProfile(1276, 320, 140, "graphic", "mono", "DM90"),
|
||||
TagProfile(1300, 172, 72, "graphic", "mono", "DM3370"),
|
||||
TagProfile(1314, 400, 300, "graphic", "mono", "SmartTag HD110"),
|
||||
TagProfile(1315, 296, 128, "graphic", "mono", "SmartTag HD L"),
|
||||
TagProfile(1317, 152, 152, "graphic", "mono", "SmartTag HD S"),
|
||||
TagProfile(1318, 208, 112, "graphic", "mono", "SmartTag HD M"),
|
||||
TagProfile(1319, 800, 480, "graphic", "mono", "SmartTag HD200"),
|
||||
TagProfile(1322, 152, 152, "graphic", "mono", "SmartTag HD S"),
|
||||
TagProfile(1324, 208, 112, "graphic", "mono", "SmartTag HD M FZ"),
|
||||
TagProfile(1327, 208, 112, "graphic", "red", "SmartTag HD M Red"),
|
||||
TagProfile(1328, 296, 128, "graphic", "red", "SmartTag HD L Red"),
|
||||
TagProfile(1336, 400, 300, "graphic", "red", "SmartTag HD110 Red"),
|
||||
TagProfile(1339, 152, 152, "graphic", "red", "SmartTag HD S Red"),
|
||||
TagProfile(1340, 800, 480, "graphic", "red", "SmartTag HD200 Red"),
|
||||
TagProfile(1344, 296, 128, "graphic", "yellow", "SmartTag HD L Yellow"),
|
||||
TagProfile(1346, 800, 480, "graphic", "yellow", "SmartTag HD200 Yellow"),
|
||||
TagProfile(1348, 264, 176, "graphic", "red", "SmartTag HD T Red"),
|
||||
TagProfile(1349, 264, 176, "graphic", "yellow", "SmartTag HD T Yellow"),
|
||||
TagProfile(1351, 648, 480, "graphic", "mono", "SmartTag HD150"),
|
||||
TagProfile(1353, 648, 480, "graphic", "red", "SmartTag HD150 Red"),
|
||||
TagProfile(1370, 296, 128, "graphic", "red", "SmartTag HD L Red 2021"),
|
||||
TagProfile(1371, 648, 480, "graphic", "red", "SmartTag HD150 Red 2021"),
|
||||
TagProfile(1627, 296, 128, "graphic", "red", "SmartTag HD L Red"),
|
||||
TagProfile(1628, 296, 128, "graphic", "red", "SmartTag HD L Red"),
|
||||
TagProfile(1639, 152, 152, "graphic", "red", "SmartTag HD S Red"),
|
||||
)
|
||||
|
||||
fun normalizeBarcode(raw: String): String {
|
||||
val compact = raw.filter { it.isLetterOrDigit() }.uppercase()
|
||||
return when {
|
||||
compact.length >= 17 && compact.firstOrNull()?.isLetter() == true -> compact.take(17)
|
||||
compact.length == 16 && compact.all { it.isDigit() } -> "N$compact"
|
||||
else -> compact.take(17)
|
||||
}
|
||||
}
|
||||
|
||||
fun isValidBarcode(barcode: String): Boolean {
|
||||
return barcode.length == 17 &&
|
||||
barcode.first().isLetter() &&
|
||||
barcode.drop(1).all { it.isDigit() }
|
||||
}
|
||||
|
||||
fun fallbackProfile(type: Int = 0): TagProfile {
|
||||
return TagProfile(
|
||||
type = type,
|
||||
width = 296,
|
||||
height = 128,
|
||||
kind = "graphic",
|
||||
color = "mono",
|
||||
model = if (type == 0) "Unknown graphic tag" else "Unknown $type",
|
||||
known = false,
|
||||
)
|
||||
}
|
||||
|
||||
fun profileFromBarcode(barcode: String): TagProfile {
|
||||
if (!isValidBarcode(barcode)) return fallbackProfile()
|
||||
val type = barcode.substring(12, 16).toIntOrNull() ?: 0
|
||||
return tagProfiles.firstOrNull { it.type == type } ?: fallbackProfile(type)
|
||||
}
|
||||
|
||||
fun tagFromBarcode(raw: String): TagRecord {
|
||||
val barcode = normalizeBarcode(raw)
|
||||
val profile = profileFromBarcode(barcode)
|
||||
return TagRecord(
|
||||
barcode = barcode,
|
||||
name = profile.model.ifBlank { barcode },
|
||||
width = profile.width,
|
||||
height = profile.height,
|
||||
color = profile.color,
|
||||
model = profile.model,
|
||||
type = profile.type,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package xyz.i12bp8.tagtinker.android.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class TagProfile(
|
||||
val type: Int,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val kind: String,
|
||||
val color: String,
|
||||
val model: String,
|
||||
val known: Boolean = true,
|
||||
)
|
||||
|
||||
data class TagRecord(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val barcode: String,
|
||||
val name: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val color: String,
|
||||
val model: String,
|
||||
val type: Int,
|
||||
)
|
||||
|
||||
enum class FitMode { Fit, Fill, Stretch }
|
||||
|
||||
enum class DitherMode { Floyd, Atkinson, Threshold }
|
||||
|
||||
data class EditorState(
|
||||
val fitMode: FitMode = FitMode.Fit,
|
||||
val ditherMode: DitherMode = DitherMode.Floyd,
|
||||
val detail: Int = 60,
|
||||
val scale: Float = 1f,
|
||||
val offsetX: Float = 0f,
|
||||
val offsetY: Float = 0f,
|
||||
val contrast: Int = 8,
|
||||
val brightness: Int = 0,
|
||||
val useColor: Boolean = false,
|
||||
val page: Int = 1,
|
||||
)
|
||||
|
||||
data class PayloadHistoryItem(
|
||||
val id: String,
|
||||
val barcode: String,
|
||||
val name: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val color: String,
|
||||
val page: Int,
|
||||
val bmpPath: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
data class BlePeripheral(
|
||||
val address: String,
|
||||
val name: String,
|
||||
val rssi: Int,
|
||||
)
|
||||
|
||||
data class UploadJob(
|
||||
val jobId: String,
|
||||
val barcode: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val page: Int,
|
||||
val bmpBytes: ByteArray,
|
||||
)
|
||||
|
||||
data class UploadProgress(
|
||||
val bytesDone: Int,
|
||||
val bytesTotal: Int,
|
||||
val label: String,
|
||||
)
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
package xyz.i12bp8.tagtinker.android.render
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import xyz.i12bp8.tagtinker.android.model.DitherMode
|
||||
import xyz.i12bp8.tagtinker.android.model.EditorState
|
||||
import xyz.i12bp8.tagtinker.android.model.FitMode
|
||||
|
||||
private val white = intArrayOf(255, 255, 255)
|
||||
private val black = intArrayOf(0, 0, 0)
|
||||
private val red = intArrayOf(218, 45, 53)
|
||||
private val yellow = intArrayOf(226, 177, 32)
|
||||
|
||||
data class RenderResult(
|
||||
val bitmap: Bitmap,
|
||||
val bmpBytes: ByteArray,
|
||||
)
|
||||
|
||||
object ImagePipeline {
|
||||
fun render(source: Bitmap, width: Int, height: Int, accentColor: String, editor: EditorState): RenderResult {
|
||||
val framed = drawSourceFrame(source, width, height, editor)
|
||||
val adjusted = adjustBitmap(framed, editor)
|
||||
val paletted = quantize(adjusted, accentColor, editor)
|
||||
return RenderResult(
|
||||
bitmap = paletted,
|
||||
bmpBytes = buildBmp(paletted, accentColor != "mono" && editor.useColor),
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawSourceFrame(source: Bitmap, width: Int, height: Int, editor: EditorState): Bitmap {
|
||||
val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(output)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
isFilterBitmap = true
|
||||
}
|
||||
|
||||
if (editor.fitMode == FitMode.Stretch) {
|
||||
canvas.drawBitmap(source, null, android.graphics.Rect(0, 0, width, height), paint)
|
||||
return output
|
||||
}
|
||||
|
||||
val scaleFactor = when (editor.fitMode) {
|
||||
FitMode.Fill -> max(width / source.width.toFloat(), height / source.height.toFloat())
|
||||
else -> min(width / source.width.toFloat(), height / source.height.toFloat())
|
||||
} * editor.scale
|
||||
|
||||
val drawWidth = max(1f, source.width * scaleFactor)
|
||||
val drawHeight = max(1f, source.height * scaleFactor)
|
||||
val left = (width - drawWidth) / 2f + editor.offsetX * width * 0.5f
|
||||
val top = (height - drawHeight) / 2f + editor.offsetY * height * 0.5f
|
||||
val matrix = Matrix().apply {
|
||||
postScale(drawWidth / source.width, drawHeight / source.height)
|
||||
postTranslate(left, top)
|
||||
}
|
||||
canvas.drawBitmap(source, matrix, paint)
|
||||
return output
|
||||
}
|
||||
|
||||
private fun adjustBitmap(bitmap: Bitmap, editor: EditorState): Bitmap {
|
||||
val out = bitmap.copy(Bitmap.Config.ARGB_8888, true)
|
||||
val pixels = IntArray(out.width * out.height)
|
||||
out.getPixels(pixels, 0, out.width, 0, 0, out.width, out.height)
|
||||
val contrast = editor.contrast
|
||||
val brightness = editor.brightness
|
||||
val factor = (259f * (contrast + 255)) / (255 * (259 - contrast))
|
||||
for (i in pixels.indices) {
|
||||
val color = pixels[i]
|
||||
val r = clamp(factor * (Color.red(color) - 128) + 128 + brightness)
|
||||
val g = clamp(factor * (Color.green(color) - 128) + 128 + brightness)
|
||||
val b = clamp(factor * (Color.blue(color) - 128) + 128 + brightness)
|
||||
pixels[i] = Color.argb(255, r, g, b)
|
||||
}
|
||||
out.setPixels(pixels, 0, out.width, 0, 0, out.width, out.height)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun quantize(bitmap: Bitmap, accentColor: String, editor: EditorState): Bitmap {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val src = IntArray(width * height)
|
||||
bitmap.getPixels(src, 0, width, 0, 0, width, height)
|
||||
val out = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val dst = IntArray(width * height)
|
||||
val palette = if (editor.useColor && accentColor != "mono") {
|
||||
arrayOf(white, black, if (accentColor == "yellow") yellow else red)
|
||||
} else {
|
||||
arrayOf(white, black)
|
||||
}
|
||||
|
||||
if (editor.ditherMode == DitherMode.Threshold) {
|
||||
src.indices.forEach { index ->
|
||||
dst[index] = nearestColor(src[index], palette, editor.detail)
|
||||
}
|
||||
} else {
|
||||
val work = FloatArray(width * height * 3)
|
||||
src.indices.forEach { index ->
|
||||
work[index * 3] = Color.red(src[index]).toFloat()
|
||||
work[index * 3 + 1] = Color.green(src[index]).toFloat()
|
||||
work[index * 3 + 2] = Color.blue(src[index]).toFloat()
|
||||
}
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
val base = (y * width + x) * 3
|
||||
val current = Color.rgb(
|
||||
clamp(work[base]),
|
||||
clamp(work[base + 1]),
|
||||
clamp(work[base + 2]),
|
||||
)
|
||||
val next = nearestColor(current, palette, editor.detail)
|
||||
dst[y * width + x] = next
|
||||
val er = Color.red(current) - Color.red(next)
|
||||
val eg = Color.green(current) - Color.green(next)
|
||||
val eb = Color.blue(current) - Color.blue(next)
|
||||
diffuse(work, width, height, x, y, er, eg, eb, editor.ditherMode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out.setPixels(dst, 0, width, 0, 0, width, height)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun diffuse(
|
||||
work: FloatArray,
|
||||
width: Int,
|
||||
height: Int,
|
||||
x: Int,
|
||||
y: Int,
|
||||
er: Int,
|
||||
eg: Int,
|
||||
eb: Int,
|
||||
mode: DitherMode,
|
||||
) {
|
||||
val points = if (mode == DitherMode.Atkinson) {
|
||||
listOf(
|
||||
intArrayOf(1, 0, 1, 8),
|
||||
intArrayOf(2, 0, 1, 8),
|
||||
intArrayOf(-1, 1, 1, 8),
|
||||
intArrayOf(0, 1, 1, 8),
|
||||
intArrayOf(1, 1, 1, 8),
|
||||
intArrayOf(0, 2, 1, 8),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
intArrayOf(1, 0, 7, 16),
|
||||
intArrayOf(-1, 1, 3, 16),
|
||||
intArrayOf(0, 1, 5, 16),
|
||||
intArrayOf(1, 1, 1, 16),
|
||||
)
|
||||
}
|
||||
|
||||
points.forEach { point ->
|
||||
val nx = x + point[0]
|
||||
val ny = y + point[1]
|
||||
if (nx !in 0 until width || ny !in 0 until height) return@forEach
|
||||
val factor = point[2].toFloat() / point[3]
|
||||
val base = (ny * width + nx) * 3
|
||||
work[base] += er * factor
|
||||
work[base + 1] += eg * factor
|
||||
work[base + 2] += eb * factor
|
||||
}
|
||||
}
|
||||
|
||||
private fun nearestColor(color: Int, palette: Array<IntArray>, detail: Int): Int {
|
||||
val threshold = 128 + (detail - 50) * 1.18f
|
||||
if (palette.size == 2) {
|
||||
val luma = Color.red(color) * 0.299f + Color.green(color) * 0.587f + Color.blue(color) * 0.114f
|
||||
return if (luma >= threshold) Color.WHITE else Color.BLACK
|
||||
}
|
||||
|
||||
var best = palette.first()
|
||||
var bestDist = Float.MAX_VALUE
|
||||
palette.forEach { candidate ->
|
||||
val dr = Color.red(color) - candidate[0]
|
||||
val dg = Color.green(color) - candidate[1]
|
||||
val db = Color.blue(color) - candidate[2]
|
||||
val dist = dr * dr * 0.92f + dg * dg + db * db * 0.86f
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return Color.rgb(best[0], best[1], best[2])
|
||||
}
|
||||
|
||||
private fun buildBmp(bitmap: Bitmap, accent: Boolean): ByteArray {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val rowStride = ((width + 31) / 32) * 4
|
||||
val colorStride = if (accent) rowStride else 0
|
||||
val pixelBytes = (rowStride + colorStride) * height
|
||||
val paletteBytes = if (accent) 12 else 8
|
||||
val headerSize = 14 + 40 + paletteBytes
|
||||
val fileSize = headerSize + pixelBytes
|
||||
val bytes = ByteArray(fileSize)
|
||||
|
||||
writeAscii(bytes, 0, "BM")
|
||||
writeIntLE(bytes, 2, fileSize)
|
||||
writeIntLE(bytes, 10, headerSize)
|
||||
writeIntLE(bytes, 14, 40)
|
||||
writeIntLE(bytes, 18, width)
|
||||
writeIntLE(bytes, 22, height)
|
||||
writeShortLE(bytes, 26, 1)
|
||||
writeShortLE(bytes, 28, if (accent) 2 else 1)
|
||||
writeIntLE(bytes, 34, pixelBytes)
|
||||
|
||||
var paletteOffset = 54
|
||||
writePalette(bytes, paletteOffset, Color.WHITE)
|
||||
paletteOffset += 4
|
||||
writePalette(bytes, paletteOffset, Color.BLACK)
|
||||
if (accent) {
|
||||
paletteOffset += 4
|
||||
var accentColor = Color.RED
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
val c = bitmap.getPixel(x, y)
|
||||
if (c != Color.WHITE && c != Color.BLACK) accentColor = c
|
||||
}
|
||||
}
|
||||
writePalette(bytes, paletteOffset, accentColor)
|
||||
}
|
||||
|
||||
val monoOffset = headerSize
|
||||
val accentOffset = headerSize + rowStride * height
|
||||
|
||||
for (y in 0 until height) {
|
||||
val flippedY = height - 1 - y
|
||||
for (x in 0 until width) {
|
||||
val color = bitmap.getPixel(x, flippedY)
|
||||
val byteIndex = x / 8
|
||||
val bit = 7 - (x % 8)
|
||||
if (color == Color.BLACK) {
|
||||
bytes[monoOffset + y * rowStride + byteIndex] =
|
||||
(bytes[monoOffset + y * rowStride + byteIndex].toInt() or (1 shl bit)).toByte()
|
||||
} else if (accent && color != Color.WHITE) {
|
||||
bytes[accentOffset + y * rowStride + byteIndex] =
|
||||
(bytes[accentOffset + y * rowStride + byteIndex].toInt() or (1 shl bit)).toByte()
|
||||
}
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
private fun writeAscii(bytes: ByteArray, offset: Int, value: String) {
|
||||
value.encodeToByteArray().copyInto(bytes, offset)
|
||||
}
|
||||
|
||||
private fun writeShortLE(bytes: ByteArray, offset: Int, value: Int) {
|
||||
bytes[offset] = (value and 0xff).toByte()
|
||||
bytes[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
}
|
||||
|
||||
private fun writeIntLE(bytes: ByteArray, offset: Int, value: Int) {
|
||||
bytes[offset] = (value and 0xff).toByte()
|
||||
bytes[offset + 1] = ((value shr 8) and 0xff).toByte()
|
||||
bytes[offset + 2] = ((value shr 16) and 0xff).toByte()
|
||||
bytes[offset + 3] = ((value shr 24) and 0xff).toByte()
|
||||
}
|
||||
|
||||
private fun writePalette(bytes: ByteArray, offset: Int, color: Int) {
|
||||
bytes[offset] = Color.blue(color).toByte()
|
||||
bytes[offset + 1] = Color.green(color).toByte()
|
||||
bytes[offset + 2] = Color.red(color).toByte()
|
||||
bytes[offset + 3] = 0
|
||||
}
|
||||
|
||||
private fun clamp(value: Float): Int = value.toInt().coerceIn(0, 255)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.TagTinker" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,4 +1,5 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.5.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
android.suppressUnsupportedCompileSdk=35
|
||||
kotlin.code.style=official
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
#Thu Aug 08 15:05:08 CEST 2019
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=$(save "$@")
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+84
@@ -0,0 +1,84 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1 @@
|
||||
sdk.dir=/home/i12bp8/Android/sdk
|
||||
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "TagTinkerBadgeCreator"
|
||||
rootProject.name = "TagTinkerAndroid"
|
||||
include(":app")
|
||||
|
||||
+4
-3
@@ -3,13 +3,13 @@ App(
|
||||
name="TagTinker",
|
||||
apptype=FlipperAppType.EXTERNAL,
|
||||
entry_point="tagtinker_app_main",
|
||||
requires=["gui", "notification", "dialogs", "storage"],
|
||||
stack_size=4 * 1024,
|
||||
requires=["gui", "notification", "dialogs", "storage", "bt"],
|
||||
stack_size=12 * 1024,
|
||||
fap_icon="tagtinker_10px.png",
|
||||
fap_category="Infrared",
|
||||
fap_author="i12bp8",
|
||||
fap_description="Educational ESL study tool for owned hardware",
|
||||
fap_version="1.3",
|
||||
fap_version="2.0",
|
||||
sources=[
|
||||
"tagtinker_app.c",
|
||||
"ir/tagtinker_ir.c",
|
||||
@@ -29,6 +29,7 @@ App(
|
||||
"scenes/tagtinker_scene_target_menu.c",
|
||||
"scenes/tagtinker_scene_text_input.c",
|
||||
"scenes/tagtinker_scene_transmit.c",
|
||||
"scenes/tagtinker_scene_text_box.c",
|
||||
"scenes/tagtinker_scene_warning.c",
|
||||
"views/numlock_input.c",
|
||||
],
|
||||
|
||||
+32
-97
@@ -2,7 +2,7 @@
|
||||
* IR transmitter.
|
||||
*
|
||||
* TIM1 CH3N drives the built-in IR LED carrier.
|
||||
* DWT->CYCCNT handles the symbol timing so we do not need another timer.
|
||||
* DWT->CYCCCNT handles the symbol timing so we do not need another timer.
|
||||
*/
|
||||
|
||||
#include "tagtinker_ir.h"
|
||||
@@ -21,52 +21,33 @@
|
||||
#define CARRIER_ARR (51 - 1)
|
||||
#define CARRIER_CCR 25
|
||||
|
||||
/* PP4 sends two bits per symbol. The gap selects the symbol value. */
|
||||
/*
|
||||
* PP4 sends two bits per symbol. The gap selects the symbol value.
|
||||
* These are pre-computed CPU cycle counts at 64 MHz to avoid any
|
||||
* per-call overhead in the tight timing loop.
|
||||
*/
|
||||
#define PP4_BURST_CYCLES 2581
|
||||
static const uint32_t pp4_gap_cycles[4] = {
|
||||
3871,
|
||||
15483,
|
||||
7741,
|
||||
11612,
|
||||
};
|
||||
|
||||
/* PP16 sends four bits per symbol. */
|
||||
#define PP16_BURST_CYCLES 1344
|
||||
static const uint32_t pp16_gap_cycles[16] = {
|
||||
1728,
|
||||
3264,
|
||||
2240,
|
||||
2752,
|
||||
9408,
|
||||
7872,
|
||||
8896,
|
||||
8384,
|
||||
5312,
|
||||
3776,
|
||||
4800,
|
||||
4288,
|
||||
5824,
|
||||
7360,
|
||||
6336,
|
||||
6848
|
||||
3871, /* symbol 0 ~60 us */
|
||||
15483, /* symbol 3 ~242 us */
|
||||
7741, /* symbol 2 ~121 us */
|
||||
11612, /* symbol 1 ~181 us */
|
||||
};
|
||||
|
||||
static bool ir_initialized = false;
|
||||
static volatile bool ir_stop_requested = false;
|
||||
|
||||
static inline void carrier_on(void) {
|
||||
/* PWM2 on CH3N gives us the carrier burst on the built-in LED. */
|
||||
uint32_t ccmr2 = CARRIER_TIM->CCMR2;
|
||||
ccmr2 &= ~(TIM_CCMR2_OC3M);
|
||||
ccmr2 |= (TIM_CCMR2_OC3M_2 | TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3M_0);
|
||||
ccmr2 |= (TIM_CCMR2_OC3M_2 | TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3M_0); /* PWM2 */
|
||||
CARRIER_TIM->CCMR2 = ccmr2;
|
||||
}
|
||||
|
||||
static inline void carrier_off(void) {
|
||||
/* Force-inactive stops the carrier between symbols. */
|
||||
uint32_t ccmr2 = CARRIER_TIM->CCMR2;
|
||||
ccmr2 &= ~(TIM_CCMR2_OC3M);
|
||||
ccmr2 |= TIM_CCMR2_OC3M_2;
|
||||
ccmr2 |= TIM_CCMR2_OC3M_2; /* Force inactive */
|
||||
CARRIER_TIM->CCMR2 = ccmr2;
|
||||
}
|
||||
|
||||
@@ -77,10 +58,8 @@ static inline void delay_cycles(uint32_t cycles) {
|
||||
}
|
||||
|
||||
static void send_frame_pp4(const uint8_t* data, size_t len) {
|
||||
/* PP4 walks each byte from least-significant bits upward, two bits at a time. */
|
||||
for(size_t byte_idx = 0; byte_idx < len; byte_idx++) {
|
||||
uint8_t current_byte = data[byte_idx];
|
||||
|
||||
for(int sym = 0; sym < 4; sym++) {
|
||||
uint8_t symbol = current_byte & 0x03;
|
||||
current_byte >>= 2;
|
||||
@@ -92,38 +71,15 @@ static void send_frame_pp4(const uint8_t* data, size_t len) {
|
||||
delay_cycles(pp4_gap_cycles[symbol]);
|
||||
}
|
||||
}
|
||||
|
||||
/* Final closing burst */
|
||||
carrier_on();
|
||||
delay_cycles(PP4_BURST_CYCLES);
|
||||
carrier_off();
|
||||
}
|
||||
|
||||
static void send_frame_pp16(const uint8_t* data, size_t len) {
|
||||
/* PP16 uses the same pattern, but four bits per symbol. */
|
||||
for(size_t byte_idx = 0; byte_idx < len; byte_idx++) {
|
||||
uint8_t current_byte = data[byte_idx];
|
||||
|
||||
for(int sym = 0; sym < 2; sym++) {
|
||||
uint8_t symbol = current_byte & 0x0F;
|
||||
current_byte >>= 4;
|
||||
|
||||
carrier_on();
|
||||
delay_cycles(PP16_BURST_CYCLES);
|
||||
|
||||
carrier_off();
|
||||
delay_cycles(pp16_gap_cycles[symbol]);
|
||||
}
|
||||
}
|
||||
|
||||
carrier_on();
|
||||
delay_cycles(PP16_BURST_CYCLES);
|
||||
carrier_off();
|
||||
}
|
||||
|
||||
void tagtinker_ir_init(void) {
|
||||
if(ir_initialized) return;
|
||||
|
||||
/* Claim TIM1 from the stock IR stack before configuring our own carrier. */
|
||||
if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) {
|
||||
furi_hal_bus_disable(FuriHalBusTIM1);
|
||||
}
|
||||
@@ -153,17 +109,14 @@ void tagtinker_ir_init(void) {
|
||||
|
||||
ir_stop_requested = false;
|
||||
ir_initialized = true;
|
||||
|
||||
FURI_LOG_I("TagTinker", "IR TX initialized: carrier %.3f MHz",
|
||||
(double)(64000000.0f / (CARRIER_ARR + 1) / 1000000.0f));
|
||||
}
|
||||
|
||||
void tagtinker_ir_deinit(void) {
|
||||
if(!ir_initialized) return;
|
||||
|
||||
tagtinker_ir_stop();
|
||||
|
||||
carrier_off();
|
||||
|
||||
LL_TIM_DisableAllOutputs(CARRIER_TIM);
|
||||
LL_TIM_CC_DisableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N);
|
||||
LL_TIM_DisableCounter(CARRIER_TIM);
|
||||
@@ -173,66 +126,48 @@ void tagtinker_ir_deinit(void) {
|
||||
}
|
||||
|
||||
furi_hal_gpio_init(&gpio_infrared_tx, GpioModeAnalog, GpioPullNo, GpioSpeedLow);
|
||||
|
||||
ir_initialized = false;
|
||||
FURI_LOG_I("TagTinker", "IR TX deinitialized");
|
||||
}
|
||||
|
||||
bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats_raw, uint8_t delay) {
|
||||
if(!ir_initialized) return false;
|
||||
if(len == 0 || len > 255) return false;
|
||||
if(!data || len == 0 || len > 255) return false;
|
||||
|
||||
ir_stop_requested = false;
|
||||
|
||||
bool is_pp16 = (repeats_raw & 0x8000) != 0;
|
||||
uint32_t repeats = repeats_raw & 0x7FFF;
|
||||
|
||||
FURI_LOG_I("TagTinker", "TX start: %zu bytes, %lu repeats (PP%d), %u delay",
|
||||
len, repeats, is_pp16 ? 16 : 4, delay);
|
||||
|
||||
for(uint32_t rep = 0; rep <= repeats; rep++) {
|
||||
if(ir_stop_requested) {
|
||||
FURI_LOG_I("TagTinker", "TX cancelled at repeat %lu", rep);
|
||||
carrier_off();
|
||||
return false;
|
||||
}
|
||||
|
||||
if(is_pp16) {
|
||||
send_frame_pp16(data, len);
|
||||
} else {
|
||||
send_frame_pp4(data, len);
|
||||
}
|
||||
/*
|
||||
* FURI_CRITICAL_ENTER/EXIT wraps each individual frame to prevent
|
||||
* OS interrupts from breaking the microsecond-level IR symbol timing.
|
||||
* The critical section is short (~1-2ms per frame) so it won't stall
|
||||
* the OS noticeably, and we yield via furi_delay_ms between repeats.
|
||||
*/
|
||||
FURI_CRITICAL_ENTER();
|
||||
send_frame_pp4(data, len);
|
||||
FURI_CRITICAL_EXIT();
|
||||
|
||||
if(rep < repeats) {
|
||||
/* Delay units are 500 us to match the ESL timing tools. */
|
||||
uint32_t delay_us = (uint32_t)delay * 500;
|
||||
if(delay_us > 0) {
|
||||
uint32_t delay_ms_yield = delay_us / 1000;
|
||||
uint32_t delay_us_busy = delay_us % 1000;
|
||||
|
||||
if(delay_ms_yield > 0) {
|
||||
furi_delay_ms(delay_ms_yield);
|
||||
}
|
||||
|
||||
if(delay_us_busy > 0) {
|
||||
delay_cycles(delay_us_busy * 64);
|
||||
}
|
||||
/* Yield to OS between repeats for smooth animation */
|
||||
if(delay > 0) {
|
||||
uint32_t delay_us = (uint32_t)delay * 500U;
|
||||
uint32_t delay_ms_yield = delay_us / 1000U;
|
||||
if(delay_ms_yield > 0) furi_delay_ms(delay_ms_yield);
|
||||
} else {
|
||||
/* Even with delay=0, yield every 10 reps to keep the OS alive */
|
||||
if((rep % 10U) == 9U) furi_delay_ms(1);
|
||||
}
|
||||
}
|
||||
|
||||
if(((uint32_t)delay * 500) < 1000 && (rep % 10) == 9) {
|
||||
furi_delay_ms(1);
|
||||
}
|
||||
}
|
||||
|
||||
FURI_LOG_I("TagTinker", "TX complete");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tagtinker_ir_is_busy(void) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void tagtinker_ir_stop(void) {
|
||||
ir_stop_requested = true;
|
||||
carrier_off();
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ void tagtinker_ir_init(void);
|
||||
|
||||
void tagtinker_ir_deinit(void);
|
||||
|
||||
/* The high bit of repeats selects PP16. The low 15 bits are the repeat count. */
|
||||
/* Repeats are sent with PP4 timing; the high bit is ignored for legacy saved data. */
|
||||
bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats, uint8_t delay);
|
||||
|
||||
bool tagtinker_ir_is_busy(void);
|
||||
|
||||
+216
-493
@@ -1,12 +1,5 @@
|
||||
/*
|
||||
* ESL protocol helpers.
|
||||
*
|
||||
* This file covers three jobs:
|
||||
* 1. Decode a barcode into the tag address and known display profile.
|
||||
* 2. Pack pixels into the tag's raw or RLE bitmap format.
|
||||
* 3. Wrap those bytes into the IR frames that the tag understands.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
#include "tagtinker_proto.h"
|
||||
@@ -20,92 +13,58 @@ typedef struct {
|
||||
uint16_t height;
|
||||
TagTinkerTagKind kind;
|
||||
TagTinkerTagColor color;
|
||||
const char* model_name;
|
||||
uint8_t pl_bit_def;
|
||||
} TagTinkerProfileEntry;
|
||||
|
||||
/* Known type codes seen in ESL barcodes. */
|
||||
static const TagTinkerProfileEntry profile_table[] = {
|
||||
{1206, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1207, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1217, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1219, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1240, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1241, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1242, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1243, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1265, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1275, 320, 192, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1276, 320, 140, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1291, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1300, 172, 72, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1314, 400, 300, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1315, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1317, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1318, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1319, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1322, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1324, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1327, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1328, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1336, 400, 300, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1339, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1340, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1344, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow},
|
||||
{1346, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow},
|
||||
{1348, 264, 176, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1349, 264, 176, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow},
|
||||
{1351, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono},
|
||||
{1353, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1354, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1370, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1371, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1510, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono},
|
||||
{1627, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1628, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1639, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed},
|
||||
{1206, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E2 HCS", 0},
|
||||
{1207, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E2 HCN", 4},
|
||||
{1217, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E5 HCS", 2},
|
||||
{1219, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E5 HCN", 1},
|
||||
{1240, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCS", 3},
|
||||
{1241, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCN", 0},
|
||||
{1242, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCN FZ", 0},
|
||||
{1243, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCW", 0},
|
||||
{1265, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E5 HCS", 2},
|
||||
{1275, 320, 192, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "DM110", 0},
|
||||
{1276, 320, 140, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "DM90", 0},
|
||||
{1291, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "FVL Promoline 3-16", 0},
|
||||
{1300, 172, 72, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "DM3370", 0},
|
||||
{1314, 400, 300, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD110", 0},
|
||||
{1315, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD L", 0},
|
||||
{1317, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD S", 0},
|
||||
{1318, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD M", 0},
|
||||
{1319, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD200", 0},
|
||||
{1322, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD S", 0},
|
||||
{1324, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD M FZ", 0},
|
||||
{1327, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD M Red", 0},
|
||||
{1328, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red", 0},
|
||||
{1336, 400, 300, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD110 Red", 0},
|
||||
{1339, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD S Red", 0},
|
||||
{1340, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD200 Red", 0},
|
||||
{1344, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow, "SmartTag HD L Yellow", 0},
|
||||
{1346, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow, "SmartTag HD200 Yellow", 0},
|
||||
{1348, 264, 176, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD T Red", 0},
|
||||
{1349, 264, 176, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow, "SmartTag HD T Yellow", 0},
|
||||
{1351, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD150", 0},
|
||||
{1353, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD150 Red", 0},
|
||||
{1354, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD150 Red", 0},
|
||||
{1370, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red (2021)", 0},
|
||||
{1371, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD150 Red (2021)", 0},
|
||||
{1510, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "SmartTag E5 M", 1},
|
||||
{1627, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red", 0},
|
||||
{1628, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red", 0},
|
||||
{1639, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD S Red", 0},
|
||||
};
|
||||
|
||||
static const TagTinkerProfileEntry* find_profile_entry(uint16_t type_code) {
|
||||
for(size_t i = 0; i < COUNT_OF(profile_table); i++) {
|
||||
if(profile_table[i].type_code == type_code) {
|
||||
return &profile_table[i];
|
||||
}
|
||||
if(profile_table[i].type_code == type_code) return &profile_table[i];
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void append_word(uint8_t* buf, size_t* pos, uint16_t value) {
|
||||
buf[(*pos)++] = (value >> 8) & 0xFF;
|
||||
buf[(*pos)++] = value & 0xFF;
|
||||
}
|
||||
|
||||
static size_t terminate(uint8_t* buf, size_t len) {
|
||||
uint16_t crc = tagtinker_crc16(buf, len);
|
||||
buf[len] = crc & 0xFF;
|
||||
buf[len + 1] = (crc >> 8) & 0xFF;
|
||||
return len + 2;
|
||||
}
|
||||
|
||||
static size_t raw_frame(uint8_t* buf, uint8_t proto,
|
||||
const uint8_t plid[4], uint8_t cmd) {
|
||||
/* Every addressed frame starts with protocol byte, PLID, then command. */
|
||||
buf[0] = proto;
|
||||
buf[1] = plid[3]; buf[2] = plid[2];
|
||||
buf[3] = plid[1]; buf[4] = plid[0];
|
||||
buf[5] = cmd;
|
||||
return 6;
|
||||
}
|
||||
|
||||
static size_t mcu_frame(uint8_t* buf, const uint8_t plid[4], uint8_t cmd) {
|
||||
/* Image upload is tunneled through command 0x34 with an inner MCU opcode. */
|
||||
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x34);
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = cmd;
|
||||
return p;
|
||||
}
|
||||
|
||||
uint16_t tagtinker_crc16(const uint8_t* data, size_t len) {
|
||||
uint16_t crc = 0x8408;
|
||||
for(size_t i = 0; i < len; i++) {
|
||||
@@ -116,363 +75,90 @@ uint16_t tagtinker_crc16(const uint8_t* data, size_t len) {
|
||||
return crc;
|
||||
}
|
||||
|
||||
static size_t terminate(uint8_t* buf, size_t len) {
|
||||
uint16_t crc = tagtinker_crc16(buf, len);
|
||||
buf[len] = crc & 0xFF;
|
||||
buf[len + 1] = (crc >> 8) & 0xFF;
|
||||
return len + 2;
|
||||
}
|
||||
|
||||
static size_t raw_frame(uint8_t* buf, uint8_t proto, const uint8_t plid[4], uint8_t cmd) {
|
||||
buf[0] = proto;
|
||||
memcpy(&buf[1], plid, 4);
|
||||
buf[5] = cmd;
|
||||
return 6;
|
||||
}
|
||||
|
||||
static size_t mcu_frame(uint8_t* buf, const uint8_t plid[4], uint8_t cmd) {
|
||||
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x34);
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = cmd;
|
||||
return p;
|
||||
}
|
||||
|
||||
static void append_word(uint8_t* buf, size_t* p, uint16_t value) {
|
||||
buf[(*p)++] = (value >> 8) & 0xFF;
|
||||
buf[(*p)++] = value & 0xFF;
|
||||
}
|
||||
|
||||
bool tagtinker_barcode_to_plid(const char* barcode, uint8_t plid[4]) {
|
||||
if(!barcode || strlen(barcode) != 17) return false;
|
||||
for(int i = 2; i < 12; i++)
|
||||
if(barcode[i] < '0' || barcode[i] > '9') return false;
|
||||
|
||||
uint32_t lo = 0, hi = 0;
|
||||
for(int i = 2; i < 7; i++) lo = lo * 10 + (barcode[i] - '0');
|
||||
for(int i = 7; i < 12; i++) hi = hi * 10 + (barcode[i] - '0');
|
||||
|
||||
uint32_t id = lo + (hi << 16);
|
||||
plid[0] = (id >> 8) & 0xFF;
|
||||
plid[1] = id & 0xFF;
|
||||
plid[2] = (id >> 24) & 0xFF;
|
||||
plid[3] = (id >> 16) & 0xFF;
|
||||
uint64_t a = 0, b = 0;
|
||||
for(int i = 2; i < 7; i++) a = a * 10 + (barcode[i] - '0');
|
||||
for(int i = 7; i < 12; i++) b = b * 10 + (barcode[i] - '0');
|
||||
|
||||
uint64_t id = (a << 16) | b;
|
||||
plid[0] = id & 0xFF; // LSB first
|
||||
plid[1] = (id >> 8) & 0xFF;
|
||||
plid[2] = (id >> 16) & 0xFF;
|
||||
plid[3] = (id >> 24) & 0xFF;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tagtinker_barcode_to_type(const char* barcode, uint16_t* type_code) {
|
||||
if(!barcode || strlen(barcode) != 17 || !type_code) return false;
|
||||
|
||||
uint16_t type = 0;
|
||||
for(int i = 12; i < 16; i++) {
|
||||
if(barcode[i] < '0' || barcode[i] > '9') return false;
|
||||
type = (uint16_t)(type * 10 + (barcode[i] - '0'));
|
||||
}
|
||||
|
||||
for(int i = 12; i < 16; i++) type = type * 10 + (barcode[i] - '0');
|
||||
*type_code = type;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tagtinker_barcode_to_profile(const char* barcode, TagTinkerTagProfile* profile) {
|
||||
if(!profile) return false;
|
||||
|
||||
memset(profile, 0, sizeof(*profile));
|
||||
|
||||
uint16_t type_code = 0;
|
||||
if(!tagtinker_barcode_to_type(barcode, &type_code)) return false;
|
||||
|
||||
profile->type_code = type_code;
|
||||
const TagTinkerProfileEntry* entry = find_profile_entry(type_code);
|
||||
if(!entry) return true;
|
||||
|
||||
profile->width = entry->width;
|
||||
profile->height = entry->height;
|
||||
profile->kind = entry->kind;
|
||||
profile->color = entry->color;
|
||||
if(!entry) return false;
|
||||
profile->width = entry->width; profile->height = entry->height;
|
||||
profile->kind = entry->kind; profile->color = entry->color;
|
||||
profile->model_name = entry->model_name; profile->pl_bit_def = entry->pl_bit_def;
|
||||
profile->known = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t tagtinker_build_broadcast_page_frame(
|
||||
uint8_t* buf, uint8_t page, bool forever, uint16_t duration) {
|
||||
|
||||
const uint8_t plid[4] = {0};
|
||||
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x06);
|
||||
buf[p++] = ((page & 7) << 3) | 0x01 | (forever ? 0x80 : 0x00);
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = forever ? 0x00 : ((duration >> 8) & 0xFF);
|
||||
buf[p++] = forever ? 0x00 : (duration & 0xFF);
|
||||
return terminate(buf, p);
|
||||
}
|
||||
|
||||
size_t tagtinker_build_broadcast_debug_frame(uint8_t* buf) {
|
||||
const uint8_t plid[4] = {0};
|
||||
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x06);
|
||||
buf[p++] = 0xF1;
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = 0x0A;
|
||||
return terminate(buf, p);
|
||||
}
|
||||
|
||||
size_t tagtinker_make_addressed_frame(
|
||||
uint8_t* buf, const uint8_t plid[4],
|
||||
const uint8_t* payload, size_t payload_len) {
|
||||
|
||||
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, payload[0]);
|
||||
memcpy(&buf[p], payload + 1, payload_len - 1);
|
||||
p += payload_len - 1;
|
||||
return terminate(buf, p);
|
||||
}
|
||||
|
||||
size_t tagtinker_make_ping_frame(uint8_t* buf, const uint8_t plid[4]) {
|
||||
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x17);
|
||||
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x97);
|
||||
buf[p++] = 0x01;
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = 0x00;
|
||||
buf[p++] = 0x00;
|
||||
for(int i = 0; i < 22; i++) buf[p++] = 0x01;
|
||||
for(int i = 0; i < 20; i++) buf[p++] = 0x01;
|
||||
return terminate(buf, p);
|
||||
}
|
||||
|
||||
size_t tagtinker_make_refresh_frame(uint8_t* buf, const uint8_t plid[4]) {
|
||||
size_t p = mcu_frame(buf, plid, 0x01);
|
||||
for(int i = 0; i < 22; i++) buf[p++] = 0x00;
|
||||
for(int i = 0; i < 18; i++) buf[p++] = 0x00;
|
||||
return terminate(buf, p);
|
||||
}
|
||||
|
||||
size_t tagtinker_make_mcu_frame(
|
||||
uint8_t* buf, const uint8_t plid[4], uint8_t cmd) {
|
||||
return mcu_frame(buf, plid, cmd);
|
||||
}
|
||||
|
||||
static void record_run(uint8_t* out, size_t* pos, size_t cap, uint32_t run_count) {
|
||||
uint8_t bits[32];
|
||||
int n = 0;
|
||||
uint32_t v = run_count;
|
||||
while(v) { bits[n++] = v & 1; v >>= 1; }
|
||||
for(int i = 0; i < n / 2; i++) {
|
||||
uint8_t t = bits[i]; bits[i] = bits[n - 1 - i]; bits[n - 1 - i] = t;
|
||||
}
|
||||
/* Runs are unary-prefixed: zeros mark bit-length, then the count bits follow. */
|
||||
for(int i = 1; i < n; i++)
|
||||
if(*pos < cap) out[(*pos)++] = 0;
|
||||
for(int i = 0; i < n; i++)
|
||||
if(*pos < cap) out[(*pos)++] = bits[i];
|
||||
}
|
||||
|
||||
size_t tagtinker_rle_compress(
|
||||
const uint8_t* pixels, size_t count,
|
||||
uint8_t* out, size_t out_cap, uint8_t* comp_type) {
|
||||
|
||||
if(count == 0) { *comp_type = 0; return 0; }
|
||||
|
||||
size_t pos = 0;
|
||||
if(pos < out_cap) out[pos++] = pixels[0];
|
||||
|
||||
uint8_t run_pixel = pixels[0];
|
||||
uint32_t run_count = 1;
|
||||
|
||||
for(size_t i = 1; i < count; i++) {
|
||||
if(pixels[i] == run_pixel) {
|
||||
run_count++;
|
||||
} else {
|
||||
record_run(out, &pos, out_cap, run_count);
|
||||
run_pixel = pixels[i];
|
||||
run_count = 1;
|
||||
}
|
||||
}
|
||||
if(run_count > 1) record_run(out, &pos, out_cap, run_count);
|
||||
|
||||
if(pos < count) {
|
||||
*comp_type = 2;
|
||||
return pos;
|
||||
}
|
||||
|
||||
memcpy(out, pixels, count < out_cap ? count : out_cap);
|
||||
*comp_type = 0;
|
||||
return count < out_cap ? count : out_cap;
|
||||
}
|
||||
|
||||
static inline uint8_t plane_pixel_at(
|
||||
const uint8_t* primary_pixels,
|
||||
const uint8_t* secondary_pixels,
|
||||
size_t pixel_count,
|
||||
size_t index) {
|
||||
if(index < pixel_count) return primary_pixels[index];
|
||||
return secondary_pixels[index - pixel_count];
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
uint8_t* data;
|
||||
size_t bit_pos;
|
||||
} TagTinkerBitWriter;
|
||||
|
||||
static inline void bit_writer_append(TagTinkerBitWriter* writer, uint8_t bit) {
|
||||
size_t byte_idx = writer->bit_pos / 8U;
|
||||
size_t bit_idx = 7U - (writer->bit_pos % 8U);
|
||||
if(bit) writer->data[byte_idx] |= (uint8_t)(1U << bit_idx);
|
||||
writer->bit_pos++;
|
||||
}
|
||||
|
||||
static size_t record_run_bit_length(uint32_t run_count) {
|
||||
size_t bit_count = 0;
|
||||
do {
|
||||
bit_count++;
|
||||
run_count >>= 1;
|
||||
} while(run_count);
|
||||
|
||||
return (bit_count * 2U) - 1U;
|
||||
}
|
||||
|
||||
static void bit_writer_append_run(TagTinkerBitWriter* writer, uint32_t run_count) {
|
||||
uint8_t bits[32];
|
||||
int n = 0;
|
||||
uint32_t v = run_count;
|
||||
while(v) {
|
||||
bits[n++] = v & 1U;
|
||||
v >>= 1;
|
||||
}
|
||||
|
||||
for(int i = 0; i < n / 2; i++) {
|
||||
uint8_t t = bits[i];
|
||||
bits[i] = bits[n - 1 - i];
|
||||
bits[n - 1 - i] = t;
|
||||
}
|
||||
|
||||
for(int i = 1; i < n; i++) bit_writer_append(writer, 0U);
|
||||
for(int i = 0; i < n; i++) bit_writer_append(writer, bits[i]);
|
||||
}
|
||||
|
||||
static size_t tagtinker_rle_planes_bit_length(
|
||||
const uint8_t* primary_pixels,
|
||||
const uint8_t* secondary_pixels,
|
||||
size_t pixel_count) {
|
||||
if(!primary_pixels) return 0;
|
||||
|
||||
size_t total_count = secondary_pixels ? (pixel_count * 2U) : pixel_count;
|
||||
if(total_count == 0) return 0;
|
||||
|
||||
size_t bit_len = 1U;
|
||||
uint8_t run_pixel = plane_pixel_at(primary_pixels, secondary_pixels, pixel_count, 0);
|
||||
uint32_t run_count = 1;
|
||||
|
||||
for(size_t i = 1; i < total_count; i++) {
|
||||
uint8_t pixel = plane_pixel_at(primary_pixels, secondary_pixels, pixel_count, i);
|
||||
if(pixel == run_pixel) {
|
||||
run_count++;
|
||||
} else {
|
||||
bit_len += record_run_bit_length(run_count);
|
||||
run_pixel = pixel;
|
||||
run_count = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if(run_count > 1U) bit_len += record_run_bit_length(run_count);
|
||||
return bit_len;
|
||||
}
|
||||
|
||||
static void tagtinker_pack_planes_raw(
|
||||
const uint8_t* primary_pixels,
|
||||
const uint8_t* secondary_pixels,
|
||||
size_t pixel_count,
|
||||
uint8_t* out) {
|
||||
size_t total_count = secondary_pixels ? (pixel_count * 2U) : pixel_count;
|
||||
TagTinkerBitWriter writer = {.data = out, .bit_pos = 0};
|
||||
|
||||
for(size_t i = 0; i < total_count; i++) {
|
||||
bit_writer_append(
|
||||
&writer, plane_pixel_at(primary_pixels, secondary_pixels, pixel_count, i));
|
||||
}
|
||||
}
|
||||
|
||||
static void tagtinker_pack_planes_rle(
|
||||
const uint8_t* primary_pixels,
|
||||
const uint8_t* secondary_pixels,
|
||||
size_t pixel_count,
|
||||
uint8_t* out) {
|
||||
size_t total_count = secondary_pixels ? (pixel_count * 2U) : pixel_count;
|
||||
if(total_count == 0) return;
|
||||
|
||||
TagTinkerBitWriter writer = {.data = out, .bit_pos = 0};
|
||||
uint8_t run_pixel = plane_pixel_at(primary_pixels, secondary_pixels, pixel_count, 0);
|
||||
uint32_t run_count = 1;
|
||||
|
||||
bit_writer_append(&writer, run_pixel);
|
||||
|
||||
for(size_t i = 1; i < total_count; i++) {
|
||||
uint8_t pixel = plane_pixel_at(primary_pixels, secondary_pixels, pixel_count, i);
|
||||
if(pixel == run_pixel) {
|
||||
run_count++;
|
||||
} else {
|
||||
bit_writer_append_run(&writer, run_count);
|
||||
run_pixel = pixel;
|
||||
run_count = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if(run_count > 1U) bit_writer_append_run(&writer, run_count);
|
||||
}
|
||||
|
||||
#define DATA_BYTES_PER_FRAME 20
|
||||
#define DATA_BITS_PER_FRAME (DATA_BYTES_PER_FRAME * 8)
|
||||
|
||||
bool tagtinker_encode_planes_payload(
|
||||
const uint8_t* primary_pixels,
|
||||
const uint8_t* secondary_pixels,
|
||||
size_t pixel_count,
|
||||
TagTinkerCompressionMode mode,
|
||||
TagTinkerImagePayload* payload) {
|
||||
if(!primary_pixels || !payload) return false;
|
||||
|
||||
memset(payload, 0, sizeof(*payload));
|
||||
|
||||
size_t total_pixels = secondary_pixels ? (pixel_count * 2U) : pixel_count;
|
||||
size_t comp_len = tagtinker_rle_planes_bit_length(primary_pixels, secondary_pixels, pixel_count);
|
||||
bool use_compressed = false;
|
||||
if(mode == TagTinkerCompressionRle) {
|
||||
use_compressed = true;
|
||||
} else if(mode == TagTinkerCompressionAuto) {
|
||||
/* Auto mode picks RLE only when it is smaller than the raw bitstream. */
|
||||
use_compressed = (comp_len > 0U) && (comp_len < total_pixels);
|
||||
}
|
||||
size_t src_len = use_compressed ? comp_len : total_pixels;
|
||||
|
||||
size_t padding = (DATA_BITS_PER_FRAME - (src_len % DATA_BITS_PER_FRAME)) % DATA_BITS_PER_FRAME;
|
||||
size_t padded_bits = src_len + padding;
|
||||
size_t padded_bytes = padded_bits / 8U;
|
||||
|
||||
uint8_t* data_bytes = calloc(padded_bytes, 1);
|
||||
if(!data_bytes) return false;
|
||||
|
||||
if(use_compressed) {
|
||||
tagtinker_pack_planes_rle(primary_pixels, secondary_pixels, pixel_count, data_bytes);
|
||||
} else {
|
||||
tagtinker_pack_planes_raw(primary_pixels, secondary_pixels, pixel_count, data_bytes);
|
||||
}
|
||||
|
||||
payload->data = data_bytes;
|
||||
payload->byte_count = padded_bytes;
|
||||
payload->comp_type = use_compressed ? 2U : 0U;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tagtinker_encode_image_payload(
|
||||
const uint8_t* pixels,
|
||||
uint16_t width,
|
||||
uint16_t height,
|
||||
bool color_clear,
|
||||
TagTinkerCompressionMode mode,
|
||||
TagTinkerImagePayload* payload) {
|
||||
size_t pixel_count = (size_t)width * height;
|
||||
uint8_t* second_plane = NULL;
|
||||
|
||||
if(color_clear) {
|
||||
second_plane = malloc(pixel_count);
|
||||
if(!second_plane) return false;
|
||||
memset(second_plane, 1, pixel_count);
|
||||
}
|
||||
|
||||
bool ok = tagtinker_encode_planes_payload(pixels, second_plane, pixel_count, mode, payload);
|
||||
free(second_plane);
|
||||
return ok;
|
||||
}
|
||||
|
||||
void tagtinker_free_image_payload(TagTinkerImagePayload* payload) {
|
||||
if(!payload) return;
|
||||
|
||||
free(payload->data);
|
||||
payload->data = NULL;
|
||||
payload->byte_count = 0;
|
||||
payload->comp_type = 0;
|
||||
}
|
||||
|
||||
size_t tagtinker_make_image_param_frame(
|
||||
uint8_t* buf,
|
||||
const uint8_t plid[4],
|
||||
uint16_t byte_count,
|
||||
uint8_t comp_type,
|
||||
uint8_t page,
|
||||
uint16_t width,
|
||||
uint16_t height,
|
||||
uint16_t pos_x,
|
||||
uint16_t pos_y) {
|
||||
/* Command 0x05 tells the tag how many bytes are coming and where to place them. */
|
||||
uint8_t* buf, const uint8_t plid[4], uint16_t byte_count, uint8_t comp_type,
|
||||
uint8_t page, uint16_t width, uint16_t height, uint16_t pos_x, uint16_t pos_y) {
|
||||
size_t p = mcu_frame(buf, plid, 0x05);
|
||||
append_word(buf, &p, byte_count);
|
||||
buf[p++] = 0x00;
|
||||
@@ -492,99 +178,136 @@ size_t tagtinker_make_image_param_frame(
|
||||
size_t tagtinker_make_image_data_frame(
|
||||
uint8_t* buf,
|
||||
const uint8_t plid[4],
|
||||
uint16_t frame_index,
|
||||
const uint8_t data_bytes[20]) {
|
||||
/* Command 0x20 carries one fixed 20-byte image block. */
|
||||
uint16_t index,
|
||||
const uint8_t data[TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME]) {
|
||||
size_t p = mcu_frame(buf, plid, 0x20);
|
||||
append_word(buf, &p, frame_index);
|
||||
memcpy(&buf[p], data_bytes, DATA_BYTES_PER_FRAME);
|
||||
p += DATA_BYTES_PER_FRAME;
|
||||
append_word(buf, &p, index);
|
||||
memcpy(&buf[p], data, TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME);
|
||||
p += TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME;
|
||||
return terminate(buf, p);
|
||||
}
|
||||
|
||||
void tagtinker_build_image_sequence(
|
||||
TagTinkerApp* app,
|
||||
const uint8_t plid[4],
|
||||
const uint8_t* pixels,
|
||||
uint16_t width, uint16_t height,
|
||||
uint8_t page,
|
||||
uint16_t pos_x, uint16_t pos_y,
|
||||
uint16_t wake_repeats) {
|
||||
|
||||
TagTinkerImagePayload payload;
|
||||
if(!tagtinker_encode_image_payload(
|
||||
pixels, width, height, app->color_clear, app->compression_mode, &payload))
|
||||
return;
|
||||
|
||||
/* The tag expects 20 data bytes per frame, so pad the payload to that boundary. */
|
||||
size_t frame_count = payload.byte_count / DATA_BYTES_PER_FRAME;
|
||||
|
||||
FURI_LOG_I("TagTinker", "IMG %ux%u pg=%u comp=%u %zu->%zu frames=%zu",
|
||||
width,
|
||||
height,
|
||||
page,
|
||||
payload.comp_type,
|
||||
(size_t)width * height * (app->color_clear ? 2U : 1U),
|
||||
payload.byte_count,
|
||||
frame_count);
|
||||
|
||||
size_t total = 2 + frame_count + 1;
|
||||
|
||||
app->frame_seq_count = total;
|
||||
app->frame_sequence = malloc(sizeof(uint8_t*) * total);
|
||||
app->frame_lengths = malloc(sizeof(size_t) * total);
|
||||
app->frame_repeats = malloc(sizeof(uint16_t) * total);
|
||||
|
||||
if(!app->frame_sequence || !app->frame_lengths || !app->frame_repeats) {
|
||||
tagtinker_free_image_payload(&payload);
|
||||
app->frame_seq_count = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
size_t idx = 0;
|
||||
|
||||
/* Wake the tag before sending the upload. */
|
||||
app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE);
|
||||
app->frame_lengths[idx] = tagtinker_make_ping_frame(app->frame_sequence[idx], plid);
|
||||
app->frame_repeats[idx] = wake_repeats;
|
||||
idx++;
|
||||
|
||||
/* The parameter frame describes size, page, compression mode, and placement. */
|
||||
app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE);
|
||||
app->frame_lengths[idx] = tagtinker_make_image_param_frame(
|
||||
app->frame_sequence[idx],
|
||||
plid,
|
||||
(uint16_t)payload.byte_count,
|
||||
payload.comp_type,
|
||||
page,
|
||||
width,
|
||||
height,
|
||||
pos_x,
|
||||
pos_y);
|
||||
app->frame_repeats[idx] = 1;
|
||||
idx++;
|
||||
|
||||
/* Data frames follow in order and carry the packed bitmap bytes. */
|
||||
for(size_t fi = 0; fi < frame_count; fi++) {
|
||||
app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE);
|
||||
size_t start = fi * DATA_BYTES_PER_FRAME;
|
||||
app->frame_lengths[idx] = tagtinker_make_image_data_frame(
|
||||
app->frame_sequence[idx], plid, (uint16_t)fi, &payload.data[start]);
|
||||
app->frame_repeats[idx] = 3;
|
||||
idx++;
|
||||
}
|
||||
|
||||
/* Refresh asks the tag to display the uploaded image. */
|
||||
app->frame_sequence[idx] = malloc(TAGTINKER_MAX_FRAME_SIZE);
|
||||
app->frame_lengths[idx] = tagtinker_make_refresh_frame(app->frame_sequence[idx], plid);
|
||||
app->frame_repeats[idx] = 1;
|
||||
|
||||
if(app->frame_seq_count > 1) {
|
||||
memcpy(app->frame_buf, app->frame_sequence[1],
|
||||
app->frame_lengths[1] < TAGTINKER_MAX_FRAME_SIZE
|
||||
? app->frame_lengths[1] : TAGTINKER_MAX_FRAME_SIZE);
|
||||
app->frame_len = app->frame_lengths[1];
|
||||
}
|
||||
|
||||
tagtinker_free_image_payload(&payload);
|
||||
typedef struct { uint8_t* data; size_t bit_pos; } TagTinkerBitWriter;
|
||||
static inline void bit_writer_append(TagTinkerBitWriter* writer, uint8_t bit) {
|
||||
size_t byte_idx = writer->bit_pos / 8U;
|
||||
size_t bit_idx = 7U - (writer->bit_pos % 8U);
|
||||
if(bit) writer->data[byte_idx] |= (uint8_t)(1U << bit_idx);
|
||||
writer->bit_pos++;
|
||||
}
|
||||
|
||||
static size_t record_run_bit_length(uint32_t count) {
|
||||
size_t bits = 0; do { bits++; count >>= 1; } while(count);
|
||||
return (bits * 2U) - 1U;
|
||||
}
|
||||
|
||||
static void bit_writer_append_run(TagTinkerBitWriter* writer, uint32_t count) {
|
||||
uint8_t bits[32]; int n = 0; uint32_t v = count;
|
||||
while(v) { bits[n++] = v & 1U; v >>= 1; }
|
||||
for(int i = 0; i < n / 2; i++) { uint8_t t = bits[i]; bits[i] = bits[n-1-i]; bits[n-1-i] = t; }
|
||||
for(int i = 1; i < n; i++) bit_writer_append(writer, 0U);
|
||||
for(int i = 0; i < n; i++) bit_writer_append(writer, bits[i]);
|
||||
}
|
||||
|
||||
static inline uint8_t plane_pixel_at(const uint8_t* p1, const uint8_t* p2, size_t count, size_t idx) {
|
||||
return (idx < count) ? p1[idx] : p2[idx - count];
|
||||
}
|
||||
|
||||
static size_t tagtinker_rle_planes_bit_length(const uint8_t* p1, const uint8_t* p2, size_t count) {
|
||||
if(!p1) return 0;
|
||||
size_t total = p2 ? (count * 2U) : count;
|
||||
if(total == 0) return 0;
|
||||
size_t bit_len = 1U;
|
||||
uint8_t run_pixel = plane_pixel_at(p1, p2, count, 0);
|
||||
uint32_t run_count = 1;
|
||||
for(size_t i = 1; i < total; i++) {
|
||||
uint8_t pix = plane_pixel_at(p1, p2, count, i);
|
||||
if(pix == run_pixel) run_count++;
|
||||
else { bit_len += record_run_bit_length(run_count); run_pixel = pix; run_count = 1; }
|
||||
}
|
||||
if(run_count > 0U) bit_len += record_run_bit_length(run_count);
|
||||
return bit_len;
|
||||
}
|
||||
|
||||
static void tagtinker_pack_planes_raw(const uint8_t* p1, const uint8_t* p2, size_t count, uint8_t* out) {
|
||||
size_t total = p2 ? (count * 2U) : count;
|
||||
TagTinkerBitWriter writer = {.data = out, .bit_pos = 0};
|
||||
for(size_t i = 0; i < total; i++) bit_writer_append(&writer, plane_pixel_at(p1, p2, count, i));
|
||||
}
|
||||
|
||||
static void tagtinker_pack_planes_rle(const uint8_t* p1, const uint8_t* p2, size_t count, uint8_t* out) {
|
||||
size_t total = p2 ? (count * 2U) : count;
|
||||
if(total == 0) return;
|
||||
TagTinkerBitWriter writer = {.data = out, .bit_pos = 0};
|
||||
uint8_t run_pixel = plane_pixel_at(p1, p2, count, 0);
|
||||
uint32_t run_count = 1;
|
||||
bit_writer_append(&writer, run_pixel);
|
||||
for(size_t i = 1; i < total; i++) {
|
||||
uint8_t pix = plane_pixel_at(p1, p2, count, i);
|
||||
if(pix == run_pixel) run_count++;
|
||||
else { bit_writer_append_run(&writer, run_count); run_pixel = pix; run_count = 1; }
|
||||
}
|
||||
if(run_count > 0U) bit_writer_append_run(&writer, run_count);
|
||||
}
|
||||
|
||||
#define DATA_BITS_PER_FRAME (TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME * 8U)
|
||||
|
||||
bool tagtinker_encode_planes_payload(
|
||||
const uint8_t* p1, const uint8_t* p2, size_t count, TagTinkerCompressionMode mode, TagTinkerImagePayload* payload) {
|
||||
if(!p1 || !payload) return false;
|
||||
memset(payload, 0, sizeof(*payload));
|
||||
size_t total = p2 ? (count * 2U) : count;
|
||||
size_t comp_len = tagtinker_rle_planes_bit_length(p1, p2, count);
|
||||
bool use_compressed = (mode == TagTinkerCompressionRle) ||
|
||||
(mode == TagTinkerCompressionAuto && comp_len > 0U && comp_len < total);
|
||||
size_t src_len = use_compressed ? comp_len : total;
|
||||
size_t padded_bits = src_len + ((DATA_BITS_PER_FRAME - (src_len % DATA_BITS_PER_FRAME)) % DATA_BITS_PER_FRAME);
|
||||
uint8_t* data = calloc(padded_bits / 8U, 1);
|
||||
if(!data) return false;
|
||||
if(use_compressed) tagtinker_pack_planes_rle(p1, p2, count, data);
|
||||
else tagtinker_pack_planes_raw(p1, p2, count, data);
|
||||
payload->data = data; payload->byte_count = padded_bits / 8U;
|
||||
payload->comp_type = use_compressed ? 2U : 0U;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool tagtinker_encode_image_payload(
|
||||
const uint8_t* pixels, uint16_t width, uint16_t height, bool color_clear,
|
||||
TagTinkerCompressionMode mode, TagTinkerImagePayload* payload) {
|
||||
size_t count = (size_t)width * height;
|
||||
uint8_t* second = NULL;
|
||||
if(color_clear) { second = malloc(count); if(!second) return false; memset(second, 1, count); }
|
||||
bool ok = tagtinker_encode_planes_payload(pixels, second, count, mode, payload);
|
||||
free(second); return ok;
|
||||
}
|
||||
|
||||
void tagtinker_free_image_payload(TagTinkerImagePayload* payload) {
|
||||
if(payload && payload->data) { free(payload->data); payload->data = NULL; }
|
||||
}
|
||||
|
||||
bool tagtinker_is_barcode_valid(const char* barcode) {
|
||||
if(!barcode || strlen(barcode) != 17) return false;
|
||||
int cs = 0; for(int i = 0; i < 16; i++) cs += (int)(barcode[i] - '0');
|
||||
return ((cs % 10) == (barcode[16] - '0'));
|
||||
}
|
||||
|
||||
size_t tagtinker_make_addressed_frame(uint8_t* buf, const uint8_t plid[4], const uint8_t* payload, size_t len) {
|
||||
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, payload[0]);
|
||||
memcpy(&buf[p], payload + 1, len - 1); p += len - 1;
|
||||
return terminate(buf, p);
|
||||
}
|
||||
|
||||
size_t tagtinker_build_broadcast_page_frame(uint8_t* buf, uint8_t page, bool forever, uint16_t duration) {
|
||||
const uint8_t plid[4] = {0};
|
||||
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x06);
|
||||
buf[p++] = ((page & 7) << 3) | 0x01 | (forever ? 0x80 : 0x00);
|
||||
buf[p++] = 0x00; buf[p++] = 0x00;
|
||||
buf[p++] = (duration >> 8) & 0xFF; buf[p++] = duration & 0xFF;
|
||||
return terminate(buf, p);
|
||||
}
|
||||
|
||||
size_t tagtinker_build_broadcast_debug_frame(uint8_t* buf) {
|
||||
const uint8_t plid[4] = {0};
|
||||
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x06);
|
||||
buf[p++] = 0xF1; buf[p++] = 0x00; buf[p++] = 0x00; buf[p++] = 0x00; buf[p++] = 0x0A;
|
||||
return terminate(buf, p);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#define TAGTINKER_PROTO_DM 0x85
|
||||
#define TAGTINKER_PROTO_SEG 0x84
|
||||
#define TAGTINKER_MAX_FRAME_SIZE 96
|
||||
#define TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME 20U
|
||||
|
||||
typedef struct TagTinkerApp TagTinkerApp;
|
||||
|
||||
@@ -40,9 +41,12 @@ typedef struct {
|
||||
uint16_t height;
|
||||
TagTinkerTagKind kind;
|
||||
TagTinkerTagColor color;
|
||||
const char* model_name;
|
||||
uint8_t pl_bit_def;
|
||||
bool known;
|
||||
} TagTinkerTagProfile;
|
||||
|
||||
bool tagtinker_is_barcode_valid(const char* barcode);
|
||||
bool tagtinker_barcode_to_plid(const char* barcode, uint8_t plid[4]);
|
||||
bool tagtinker_barcode_to_type(const char* barcode, uint16_t* type_code);
|
||||
bool tagtinker_barcode_to_profile(const char* barcode, TagTinkerTagProfile* profile);
|
||||
@@ -87,7 +91,7 @@ size_t tagtinker_make_image_data_frame(
|
||||
uint8_t* buf,
|
||||
const uint8_t plid[4],
|
||||
uint16_t frame_index,
|
||||
const uint8_t data_bytes[20]);
|
||||
const uint8_t data_bytes[TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME]);
|
||||
|
||||
/* Broadcast frames address every listening tag. */
|
||||
size_t tagtinker_build_broadcast_page_frame(
|
||||
|
||||
@@ -20,6 +20,7 @@ void(*const tagtinker_scene_on_enter_handlers[])(void*) = {
|
||||
tagtinker_scene_image_options_on_enter,
|
||||
tagtinker_scene_transmit_on_enter,
|
||||
tagtinker_scene_about_on_enter,
|
||||
tagtinker_scene_text_box_on_enter,
|
||||
};
|
||||
|
||||
bool(*const tagtinker_scene_on_event_handlers[])(void*, SceneManagerEvent) = {
|
||||
@@ -38,6 +39,7 @@ bool(*const tagtinker_scene_on_event_handlers[])(void*, SceneManagerEvent) = {
|
||||
tagtinker_scene_image_options_on_event,
|
||||
tagtinker_scene_transmit_on_event,
|
||||
tagtinker_scene_about_on_event,
|
||||
tagtinker_scene_text_box_on_event,
|
||||
};
|
||||
|
||||
void(*const tagtinker_scene_on_exit_handlers[])(void*) = {
|
||||
@@ -56,6 +58,7 @@ void(*const tagtinker_scene_on_exit_handlers[])(void*) = {
|
||||
tagtinker_scene_image_options_on_exit,
|
||||
tagtinker_scene_transmit_on_exit,
|
||||
tagtinker_scene_about_on_exit,
|
||||
tagtinker_scene_text_box_on_exit,
|
||||
};
|
||||
|
||||
const SceneManagerHandlers tagtinker_scene_handlers = {
|
||||
|
||||
@@ -22,6 +22,7 @@ typedef enum {
|
||||
TagTinkerSceneImageOptions,
|
||||
TagTinkerSceneTransmit,
|
||||
TagTinkerSceneAbout,
|
||||
TagTinkerSceneTextBox,
|
||||
TagTinkerSceneCount,
|
||||
} TagTinkerScene;
|
||||
|
||||
@@ -84,3 +85,7 @@ void tagtinker_scene_transmit_on_exit(void* ctx);
|
||||
void tagtinker_scene_about_on_enter(void* ctx);
|
||||
bool tagtinker_scene_about_on_event(void* ctx, SceneManagerEvent event);
|
||||
void tagtinker_scene_about_on_exit(void* ctx);
|
||||
|
||||
void tagtinker_scene_text_box_on_enter(void* ctx);
|
||||
bool tagtinker_scene_text_box_on_event(void* ctx, SceneManagerEvent event);
|
||||
void tagtinker_scene_text_box_on_exit(void* ctx);
|
||||
|
||||
+209
-380
@@ -20,8 +20,14 @@ typedef struct {
|
||||
char status_text[32];
|
||||
bool can_send_latest;
|
||||
char target_name[TAGTINKER_TARGET_NAME_LEN + 1];
|
||||
uint32_t total_rx;
|
||||
uint8_t last_bytes[3];
|
||||
} AboutViewModel;
|
||||
|
||||
/* Prototypes */
|
||||
static uint16_t ble_rx_callback(SerialServiceEvent event, void* context);
|
||||
static void bt_status_cb(BtStatus status, void* context);
|
||||
|
||||
static void ble_set_status(TagTinkerApp* app, const char* text) {
|
||||
if(!app || !text) return;
|
||||
snprintf(app->ble_status_text, sizeof(app->ble_status_text), "%s", text);
|
||||
@@ -29,7 +35,6 @@ static void ble_set_status(TagTinkerApp* app, const char* text) {
|
||||
|
||||
static void ble_send_line(TagTinkerApp* app, const char* line) {
|
||||
if(!app || !app->ble_serial || !line) return;
|
||||
|
||||
uint8_t buf[256];
|
||||
size_t n = strlen(line);
|
||||
if(n > sizeof(buf) - 2U) n = sizeof(buf) - 2U;
|
||||
@@ -38,14 +43,88 @@ static void ble_send_line(TagTinkerApp* app, const char* line) {
|
||||
ble_profile_serial_tx(app->ble_serial, buf, (uint16_t)n);
|
||||
}
|
||||
|
||||
static void ble_set_rx_status(TagTinkerApp* app, const char* line) {
|
||||
if(!app || !line) return;
|
||||
snprintf(app->ble_status_text, sizeof(app->ble_status_text), "RX %.24s", line);
|
||||
static void ble_configure_serial(TagTinkerApp* app) {
|
||||
if(!app || !app->ble_serial || app->ble_serial_configured) return;
|
||||
ble_profile_serial_set_event_callback(
|
||||
app->ble_serial, TAGTINKER_BLE_FLOW_WINDOW, ble_rx_callback, app);
|
||||
ble_profile_serial_set_rpc_active(app->ble_serial, false);
|
||||
app->ble_serial_configured = true;
|
||||
}
|
||||
|
||||
static void ble_sync_start(TagTinkerApp* app) {
|
||||
if(!app || !app->bt || app->ble_sync_active) return;
|
||||
|
||||
app->ble_total_rx = 0;
|
||||
memset(app->ble_last_bytes, 0, 3);
|
||||
app->ble_rx_len = 0;
|
||||
app->ble_rx_pending_ready = false;
|
||||
app->ble_serial_configured = false;
|
||||
app->ble_sync_active = true;
|
||||
|
||||
bt_disconnect(app->bt);
|
||||
bt_set_status_changed_callback(app->bt, bt_status_cb, app);
|
||||
app->ble_serial = bt_profile_start(app->bt, ble_profile_serial, NULL);
|
||||
|
||||
if(!app->ble_serial) {
|
||||
ble_set_status(app, "Serial Start Fail");
|
||||
} else {
|
||||
ble_set_status(app, "Waiting phone");
|
||||
}
|
||||
}
|
||||
|
||||
static void sync_clear_active_job(TagTinkerApp* app);
|
||||
|
||||
static void ble_sync_stop(TagTinkerApp* app) {
|
||||
if(!app || !app->ble_sync_active) return;
|
||||
sync_clear_active_job(app);
|
||||
bt_set_status_changed_callback(app->bt, NULL, NULL);
|
||||
bt_disconnect(app->bt);
|
||||
bt_profile_restore_default(app->bt);
|
||||
app->ble_serial = NULL;
|
||||
app->ble_serial_configured = false;
|
||||
app->ble_sync_active = false;
|
||||
}
|
||||
|
||||
static uint16_t ble_rx_callback(SerialServiceEvent event, void* context) {
|
||||
TagTinkerApp* app = context;
|
||||
if(event.event == SerialServiceEventTypeDataReceived) {
|
||||
app->ble_total_rx += event.data.size;
|
||||
for(size_t i = 0; i < 3 && i < event.data.size; i++) {
|
||||
app->ble_last_bytes[i] = event.data.buffer[i];
|
||||
}
|
||||
for(uint16_t i = 0; i < event.data.size; i++) {
|
||||
char c = (char)event.data.buffer[i];
|
||||
if(c == '\n' || c == '\r') {
|
||||
if(app->ble_rx_len > 0U && !app->ble_rx_pending_ready) {
|
||||
app->ble_rx_line[app->ble_rx_len] = '\0';
|
||||
strncpy(app->ble_rx_pending_line, app->ble_rx_line, 1023);
|
||||
app->ble_rx_pending_ready = true;
|
||||
app->ble_rx_len = 0;
|
||||
}
|
||||
} else if(!app->ble_rx_pending_ready && app->ble_rx_len < 1023U) {
|
||||
app->ble_rx_line[app->ble_rx_len++] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(app->ble_rx_pending_ready) return 0U;
|
||||
return (uint16_t)(1023U - app->ble_rx_len);
|
||||
}
|
||||
|
||||
static void bt_status_cb(BtStatus status, void* context) {
|
||||
TagTinkerApp* app = context;
|
||||
app->ble_status = status;
|
||||
if(status == BtStatusConnected) {
|
||||
/* Configure the serial callback immediately on connection */
|
||||
ble_configure_serial(app);
|
||||
ble_set_status(app, "Connected");
|
||||
ble_send_line(app, "TT_HELLO");
|
||||
} else if(status == BtStatusAdvertising) {
|
||||
ble_set_status(app, "Waiting phone");
|
||||
}
|
||||
}
|
||||
|
||||
static char* sync_next_token(char** cursor) {
|
||||
if(!cursor || !*cursor) return NULL;
|
||||
|
||||
char* token = *cursor;
|
||||
char* sep = strchr(token, '|');
|
||||
if(sep) {
|
||||
@@ -54,16 +133,13 @@ static char* sync_next_token(char** cursor) {
|
||||
} else {
|
||||
*cursor = NULL;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
static bool sync_safe_token(const char* value, size_t max_len) {
|
||||
if(!value || !*value) return false;
|
||||
|
||||
size_t len = strlen(value);
|
||||
if(len == 0U || len > max_len) return false;
|
||||
|
||||
for(size_t i = 0; i < len; i++) {
|
||||
char c = value[i];
|
||||
if((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
@@ -72,36 +148,30 @@ static bool sync_safe_token(const char* value, size_t max_len) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static void sync_send_targets(TagTinkerApp* app) {
|
||||
if(!app) return;
|
||||
|
||||
char line[96];
|
||||
snprintf(line, sizeof(line), "TT_TARGETS_BEGIN|%u", app->target_count);
|
||||
ble_send_line(app, line);
|
||||
|
||||
for(uint8_t i = 0; i < app->target_count; i++) {
|
||||
const TagTinkerTarget* target = &app->targets[i];
|
||||
snprintf(
|
||||
line,
|
||||
sizeof(line),
|
||||
"TT_TARGET|%s|%s|%u|%u",
|
||||
target->barcode,
|
||||
target->name,
|
||||
target->profile.width,
|
||||
target->profile.height);
|
||||
snprintf(line, sizeof(line), "TT_TARGET|%s|%s|%u|%u",
|
||||
target->barcode, target->name, target->profile.width, target->profile.height);
|
||||
ble_send_line(app, line);
|
||||
}
|
||||
|
||||
ble_send_line(app, "TT_TARGETS_END");
|
||||
}
|
||||
|
||||
static void sync_clear_active_job(TagTinkerApp* app) {
|
||||
if(!app) return;
|
||||
|
||||
if(app->ble_sync_file) {
|
||||
storage_file_close(app->ble_sync_file);
|
||||
storage_file_free(app->ble_sync_file);
|
||||
app->ble_sync_file = NULL;
|
||||
}
|
||||
app->ble_sync_job_active = false;
|
||||
app->ble_sync_compact_protocol = false;
|
||||
app->ble_sync_job_id[0] = '\0';
|
||||
@@ -115,14 +185,15 @@ static void sync_clear_active_job(TagTinkerApp* app) {
|
||||
|
||||
static void sync_abort_active_job(TagTinkerApp* app) {
|
||||
if(!app) return;
|
||||
|
||||
if(app->ble_sync_temp_path[0] != '\0') {
|
||||
char temp_path[TAGTINKER_IMAGE_PATH_LEN + 1];
|
||||
strncpy(temp_path, app->ble_sync_temp_path, TAGTINKER_IMAGE_PATH_LEN);
|
||||
temp_path[TAGTINKER_IMAGE_PATH_LEN] = '\0';
|
||||
sync_clear_active_job(app);
|
||||
if(temp_path[0] != '\0') {
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
storage_common_remove(storage, app->ble_sync_temp_path);
|
||||
storage_common_remove(storage, temp_path);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
}
|
||||
|
||||
sync_clear_active_job(app);
|
||||
}
|
||||
|
||||
static bool sync_append_index_record(
|
||||
@@ -134,13 +205,11 @@ static bool sync_append_index_record(
|
||||
const char* image_path) {
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
storage_common_mkdir(storage, APP_DATA_PATH(""));
|
||||
|
||||
File* file = storage_file_alloc(storage);
|
||||
bool ok = storage_file_open(file, TAGTINKER_SYNC_INDEX_PATH, FSAM_WRITE, FSOM_OPEN_APPEND);
|
||||
if(!ok) {
|
||||
ok = storage_file_open(file, TAGTINKER_SYNC_INDEX_PATH, FSAM_WRITE, FSOM_CREATE_ALWAYS);
|
||||
}
|
||||
|
||||
if(ok) {
|
||||
char line[384];
|
||||
int len = snprintf(
|
||||
@@ -157,7 +226,6 @@ static bool sync_append_index_record(
|
||||
(storage_file_write(file, line, (uint16_t)len) == (uint16_t)len);
|
||||
storage_file_close(file);
|
||||
}
|
||||
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
return ok;
|
||||
@@ -179,35 +247,27 @@ static bool sync_decode_base64(
|
||||
size_t output_size,
|
||||
size_t* output_len) {
|
||||
if(!input || !output || !output_len) return false;
|
||||
|
||||
size_t out_len = 0;
|
||||
uint8_t quartet[4];
|
||||
uint8_t quartet_len = 0;
|
||||
uint8_t padding = 0;
|
||||
|
||||
for(const char* p = input; *p; p++) {
|
||||
int8_t value = sync_base64_value(*p);
|
||||
if(value == -1) return false;
|
||||
|
||||
if(value == -2) {
|
||||
value = 0;
|
||||
padding++;
|
||||
}
|
||||
|
||||
quartet[quartet_len++] = (uint8_t)value;
|
||||
if(quartet_len != 4U) continue;
|
||||
|
||||
if(out_len + 3U > output_size) return false;
|
||||
output[out_len++] = (uint8_t)((quartet[0] << 2U) | (quartet[1] >> 4U));
|
||||
if(padding < 2U) output[out_len++] = (uint8_t)((quartet[1] << 4U) | (quartet[2] >> 2U));
|
||||
if(padding == 0U) output[out_len++] = (uint8_t)((quartet[2] << 6U) | quartet[3]);
|
||||
|
||||
quartet_len = 0;
|
||||
padding = 0;
|
||||
}
|
||||
|
||||
if(quartet_len != 0U) return false;
|
||||
|
||||
*output_len = out_len;
|
||||
return true;
|
||||
}
|
||||
@@ -226,13 +286,10 @@ static bool sync_begin_job(
|
||||
page > 7U || byte_count == 0U) {
|
||||
return false;
|
||||
}
|
||||
|
||||
sync_abort_active_job(app);
|
||||
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
storage_common_mkdir(storage, APP_DATA_PATH(""));
|
||||
storage_common_mkdir(storage, TAGTINKER_SYNC_DIR);
|
||||
|
||||
snprintf(
|
||||
app->ble_sync_temp_path,
|
||||
sizeof(app->ble_sync_temp_path),
|
||||
@@ -245,20 +302,18 @@ static bool sync_begin_job(
|
||||
"%s/%s.bmp",
|
||||
TAGTINKER_SYNC_DIR,
|
||||
job_id);
|
||||
|
||||
storage_common_remove(storage, app->ble_sync_temp_path);
|
||||
storage_common_remove(storage, app->ble_sync_final_path);
|
||||
|
||||
File* file = storage_file_alloc(storage);
|
||||
bool ok = storage_file_open(file, app->ble_sync_temp_path, FSAM_WRITE, FSOM_CREATE_ALWAYS);
|
||||
if(ok) {
|
||||
storage_file_close(file);
|
||||
}
|
||||
|
||||
storage_file_free(file);
|
||||
|
||||
app->ble_sync_file = storage_file_alloc(storage);
|
||||
bool ok = storage_file_open(app->ble_sync_file, app->ble_sync_temp_path, FSAM_WRITE, FSOM_CREATE_ALWAYS);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
if(!ok) return false;
|
||||
|
||||
if(!ok) {
|
||||
storage_file_free(app->ble_sync_file);
|
||||
app->ble_sync_file = NULL;
|
||||
return false;
|
||||
}
|
||||
|
||||
strncpy(app->ble_sync_job_id, job_id, TAGTINKER_SYNC_JOB_ID_LEN);
|
||||
app->ble_sync_job_id[TAGTINKER_SYNC_JOB_ID_LEN] = '\0';
|
||||
if(barcode && *barcode) {
|
||||
@@ -277,89 +332,43 @@ static bool sync_begin_job(
|
||||
app->ble_sync_job_active = true;
|
||||
app->ble_sync_compact_protocol = compact_protocol;
|
||||
app->ble_sync_ready_target = -1;
|
||||
app->ble_status_text[0] = '\0';
|
||||
ble_set_status(app, "Upload started");
|
||||
ble_send_line(app, compact_protocol ? "AB" : "TT_ACK|BEGIN");
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool sync_set_job_barcode(TagTinkerApp* app, const char* barcode) {
|
||||
if(!app || !app->ble_sync_job_active || !barcode || !sync_safe_token(barcode, TAGTINKER_BC_LEN)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(app->ble_sync_barcode[0] != '\0' && strcmp(app->ble_sync_barcode, barcode) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
strncpy(app->ble_sync_barcode, barcode, TAGTINKER_BC_LEN);
|
||||
app->ble_sync_barcode[TAGTINKER_BC_LEN] = '\0';
|
||||
ble_send_line(app, app->ble_sync_compact_protocol ? "AT" : "TT_ACK|TARGET");
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool sync_append_chunk(TagTinkerApp* app, uint16_t sequence, const char* payload) {
|
||||
if(!app || !app->ble_sync_job_active || !payload || sequence == 0U) return false;
|
||||
|
||||
if(!app || !app->ble_sync_job_active || !payload || sequence == 0U || !app->ble_sync_file) return false;
|
||||
if(sequence == app->ble_sync_last_chunk) {
|
||||
char ack[32];
|
||||
if(app->ble_sync_compact_protocol) {
|
||||
snprintf(ack, sizeof(ack), "A%04X", sequence);
|
||||
} else {
|
||||
snprintf(ack, sizeof(ack), "TT_ACK|%u", sequence);
|
||||
}
|
||||
snprintf(ack, sizeof(ack), "TT_ACK|%u", sequence);
|
||||
ble_send_line(app, ack);
|
||||
return true;
|
||||
}
|
||||
|
||||
if(sequence != (uint16_t)(app->ble_sync_last_chunk + 1U)) return false;
|
||||
|
||||
uint8_t decoded[TAGTINKER_SYNC_MAX_CHUNK_BYTES];
|
||||
size_t decoded_len = 0;
|
||||
if(!sync_decode_base64(payload, decoded, sizeof(decoded), &decoded_len)) return false;
|
||||
if((app->ble_sync_received_bytes + decoded_len) > app->ble_sync_expected_bytes) return false;
|
||||
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
File* file = storage_file_alloc(storage);
|
||||
bool ok = storage_file_open(file, app->ble_sync_temp_path, FSAM_WRITE, FSOM_OPEN_APPEND);
|
||||
if(ok) {
|
||||
ok = storage_file_write(file, decoded, decoded_len) == decoded_len;
|
||||
storage_file_close(file);
|
||||
}
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
if(!ok) return false;
|
||||
|
||||
if(storage_file_write(app->ble_sync_file, decoded, decoded_len) != decoded_len) return false;
|
||||
|
||||
app->ble_sync_received_bytes += decoded_len;
|
||||
app->ble_sync_last_chunk = sequence;
|
||||
app->ble_synced_lines = sequence;
|
||||
|
||||
snprintf(app->ble_status_text, sizeof(app->ble_status_text), "RX %u chunks", sequence);
|
||||
|
||||
char ack[32];
|
||||
if(app->ble_sync_compact_protocol) {
|
||||
snprintf(ack, sizeof(ack), "A%04X", sequence);
|
||||
} else {
|
||||
snprintf(ack, sizeof(ack), "TT_ACK|%u", sequence);
|
||||
}
|
||||
snprintf(ack, sizeof(ack), "TT_ACK|%u", sequence);
|
||||
ble_send_line(app, ack);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool sync_finish_job(TagTinkerApp* app, const char* job_id) {
|
||||
if(!app || !sync_safe_token(job_id, TAGTINKER_SYNC_JOB_ID_LEN)) return false;
|
||||
|
||||
if(!app->ble_sync_job_active && strcmp(job_id, app->ble_sync_last_job_id) == 0) {
|
||||
char ack[32];
|
||||
if(app->ble_sync_last_compact_protocol) {
|
||||
snprintf(ack, sizeof(ack), "AE");
|
||||
} else {
|
||||
snprintf(ack, sizeof(ack), "TT_ACK|END|%u", app->ble_sync_last_completed_chunks);
|
||||
}
|
||||
ble_send_line(app, ack);
|
||||
ble_send_line(app, "TT_ACK|END");
|
||||
return true;
|
||||
}
|
||||
|
||||
if(!app->ble_sync_job_active || strcmp(job_id, app->ble_sync_job_id) != 0) return false;
|
||||
if(app->ble_sync_barcode[0] == '\0') {
|
||||
ble_set_status(app, "No target");
|
||||
@@ -370,17 +379,20 @@ static bool sync_finish_job(TagTinkerApp* app, const char* job_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(app->ble_sync_file) {
|
||||
storage_file_close(app->ble_sync_file);
|
||||
storage_file_free(app->ble_sync_file);
|
||||
app->ble_sync_file = NULL;
|
||||
}
|
||||
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
storage_common_remove(storage, app->ble_sync_final_path);
|
||||
bool ok =
|
||||
storage_common_rename(storage, app->ble_sync_temp_path, app->ble_sync_final_path) ==
|
||||
FSE_OK;
|
||||
bool ok = storage_common_rename(storage, app->ble_sync_temp_path, app->ble_sync_final_path) == FSE_OK;
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
if(!ok) {
|
||||
ble_set_status(app, "Save failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
ok = sync_append_index_record(
|
||||
app->ble_sync_job_id,
|
||||
app->ble_sync_barcode,
|
||||
@@ -392,18 +404,13 @@ static bool sync_finish_job(TagTinkerApp* app, const char* job_id) {
|
||||
ble_set_status(app, "Index failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
strncpy(app->ble_sync_last_job_id, app->ble_sync_job_id, TAGTINKER_SYNC_JOB_ID_LEN);
|
||||
app->ble_sync_last_job_id[TAGTINKER_SYNC_JOB_ID_LEN] = '\0';
|
||||
app->ble_sync_last_completed_chunks = app->ble_sync_last_chunk;
|
||||
app->ble_sync_last_compact_protocol = app->ble_sync_compact_protocol;
|
||||
|
||||
char ack[32];
|
||||
if(app->ble_sync_compact_protocol) {
|
||||
snprintf(ack, sizeof(ack), "AE");
|
||||
} else {
|
||||
snprintf(ack, sizeof(ack), "TT_ACK|END|%u", app->ble_sync_last_chunk);
|
||||
}
|
||||
|
||||
ble_send_line(app, "TT_ACK|END");
|
||||
|
||||
int8_t target_index = tagtinker_ensure_target(app, app->ble_sync_barcode);
|
||||
if(target_index >= 0) {
|
||||
tagtinker_select_target(app, (uint8_t)target_index);
|
||||
@@ -417,39 +424,31 @@ static bool sync_finish_job(TagTinkerApp* app, const char* job_id) {
|
||||
app->ble_sync_ready_target = -1;
|
||||
ble_set_status(app, "Saved on Flipper");
|
||||
}
|
||||
ble_send_line(app, ack);
|
||||
sync_clear_active_job(app);
|
||||
app->ble_sync_job_active = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void sync_apply_line(TagTinkerApp* app, const char* line) {
|
||||
if(!app || !line) return;
|
||||
|
||||
/*
|
||||
* Compact upload protocol:
|
||||
* B<job><w><h><page><size> begin upload
|
||||
* C<barcode> bind upload to a target
|
||||
* D<seq><base64> append one chunk
|
||||
* E<job> finish upload
|
||||
*
|
||||
* Acks are AB, AT, A<seq>, and AE.
|
||||
*/
|
||||
if(strcmp(line, "TT_PING") == 0) {
|
||||
ble_set_status(app, "RX ping");
|
||||
if(strcmp(line, "TT_HELLO") == 0) {
|
||||
ble_send_line(app, "TT_PONG");
|
||||
return;
|
||||
}
|
||||
if(strcmp(line, "TT_PING") == 0) {
|
||||
ble_set_status(app, "Handshake OK");
|
||||
ble_send_line(app, "TT_PONG");
|
||||
return;
|
||||
}
|
||||
|
||||
if(strcmp(line, "TT_LIST_TARGETS") == 0) {
|
||||
ble_set_status(app, "Sending targets");
|
||||
sync_send_targets(app);
|
||||
return;
|
||||
}
|
||||
|
||||
if(strncmp(line, "TT_BEGIN|", 9) == 0) {
|
||||
char temp[160];
|
||||
ble_set_status(app, "Got BEGIN");
|
||||
char temp[256];
|
||||
strncpy(temp, line, sizeof(temp) - 1U);
|
||||
temp[sizeof(temp) - 1U] = '\0';
|
||||
|
||||
char* cursor = temp;
|
||||
sync_next_token(&cursor);
|
||||
char* job_id = sync_next_token(&cursor);
|
||||
@@ -458,9 +457,8 @@ static void sync_apply_line(TagTinkerApp* app, const char* line) {
|
||||
char* height = sync_next_token(&cursor);
|
||||
char* page = sync_next_token(&cursor);
|
||||
char* bytes = sync_next_token(&cursor);
|
||||
|
||||
if(job_id && barcode && width && height && page && bytes &&
|
||||
sync_begin_job(
|
||||
if(job_id && barcode && width && height && page && bytes) {
|
||||
if(sync_begin_job(
|
||||
app,
|
||||
job_id,
|
||||
barcode,
|
||||
@@ -469,252 +467,74 @@ static void sync_apply_line(TagTinkerApp* app, const char* line) {
|
||||
(uint8_t)atoi(page),
|
||||
(uint32_t)strtoul(bytes, NULL, 10),
|
||||
false)) {
|
||||
return;
|
||||
return;
|
||||
} else {
|
||||
ble_set_status(app, "BEGIN JOB FAIL");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ble_set_status(app, "BEGIN failed");
|
||||
ble_set_status(app, "BEGIN PARSE FAIL");
|
||||
return;
|
||||
}
|
||||
|
||||
if(strncmp(line, "TT_DATA|", 8) == 0) {
|
||||
char temp[1024];
|
||||
strncpy(temp, line, sizeof(temp) - 1U);
|
||||
temp[sizeof(temp) - 1U] = '\0';
|
||||
|
||||
char* cursor = temp;
|
||||
sync_next_token(&cursor);
|
||||
char* seq = sync_next_token(&cursor);
|
||||
char* payload = sync_next_token(&cursor);
|
||||
|
||||
if(seq && payload && sync_append_chunk(app, (uint16_t)atoi(seq), payload)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ble_set_status(app, "DATA failed");
|
||||
return;
|
||||
}
|
||||
|
||||
size_t compact_len = strlen(line);
|
||||
if(line[0] == 'B' && compact_len >= 18U && compact_len <= 20U) {
|
||||
char job_id[7];
|
||||
char width_hex[4];
|
||||
char height_hex[4];
|
||||
char page_hex[2];
|
||||
char size_hex[7];
|
||||
|
||||
memcpy(job_id, line + 1, 6);
|
||||
job_id[6] = '\0';
|
||||
memcpy(width_hex, line + 7, 3);
|
||||
width_hex[3] = '\0';
|
||||
memcpy(height_hex, line + 10, 3);
|
||||
height_hex[3] = '\0';
|
||||
memcpy(page_hex, line + 13, 1);
|
||||
page_hex[1] = '\0';
|
||||
size_t size_hex_len = compact_len - 14U;
|
||||
memcpy(size_hex, line + 14, size_hex_len);
|
||||
size_hex[size_hex_len] = '\0';
|
||||
|
||||
if(sync_begin_job(
|
||||
app,
|
||||
job_id,
|
||||
NULL,
|
||||
(uint16_t)strtoul(width_hex, NULL, 16),
|
||||
(uint16_t)strtoul(height_hex, NULL, 16),
|
||||
(uint8_t)strtoul(page_hex, NULL, 16),
|
||||
(uint32_t)strtoul(size_hex, NULL, 16),
|
||||
true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ble_set_status(app, "BEGIN failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if(line[0] == 'C' && strlen(line) == 18U) {
|
||||
if(sync_set_job_barcode(app, line + 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ble_set_status(app, "TARGET failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if(line[0] == 'D' && strlen(line) > 5U) {
|
||||
char seq_hex[5];
|
||||
memcpy(seq_hex, line + 1, 4);
|
||||
seq_hex[4] = '\0';
|
||||
|
||||
if(sync_append_chunk(app, (uint16_t)strtoul(seq_hex, NULL, 16), line + 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ble_set_status(app, "DATA failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if(strncmp(line, "TT_END|", 7) == 0) {
|
||||
const char* job_id = line + 7;
|
||||
if(sync_finish_job(app, job_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ble_set_status(app, "END failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if(line[0] == 'E' && strlen(line) == 7U) {
|
||||
if(sync_finish_job(app, line + 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ble_set_status(app, "END failed");
|
||||
return;
|
||||
}
|
||||
|
||||
ble_set_rx_status(app, line);
|
||||
}
|
||||
|
||||
static uint16_t ble_rx_callback(SerialServiceEvent event, void* context) {
|
||||
TagTinkerApp* app = context;
|
||||
if(event.event == SerialServiceEventTypeDataReceived) {
|
||||
for(uint16_t i = 0; i < event.data.size; i++) {
|
||||
char c = (char)event.data.buffer[i];
|
||||
if(c == '\n' || c == '\r') {
|
||||
if(app->ble_rx_len > 0U && !app->ble_rx_pending_ready) {
|
||||
app->ble_rx_line[app->ble_rx_len] = '\0';
|
||||
strncpy(
|
||||
app->ble_rx_pending_line,
|
||||
app->ble_rx_line,
|
||||
sizeof(app->ble_rx_pending_line) - 1U);
|
||||
app->ble_rx_pending_line[sizeof(app->ble_rx_pending_line) - 1U] = '\0';
|
||||
app->ble_rx_pending_ready = true;
|
||||
app->ble_rx_len = 0;
|
||||
}
|
||||
} else if(!app->ble_rx_pending_ready && app->ble_rx_len < (sizeof(app->ble_rx_line) - 1U)) {
|
||||
app->ble_rx_line[app->ble_rx_len++] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(app->ble_rx_pending_ready) return 0U;
|
||||
return (uint16_t)((sizeof(app->ble_rx_line) - 1U) - app->ble_rx_len);
|
||||
}
|
||||
|
||||
static void bt_status_cb(BtStatus status, void* context) {
|
||||
TagTinkerApp* app = context;
|
||||
app->ble_status = status;
|
||||
|
||||
switch(status) {
|
||||
case BtStatusConnected:
|
||||
if(app->ble_serial) {
|
||||
ble_profile_serial_set_event_callback(
|
||||
app->ble_serial, TAGTINKER_BLE_FLOW_WINDOW, ble_rx_callback, app);
|
||||
ble_profile_serial_set_rpc_active(app->ble_serial, false);
|
||||
}
|
||||
ble_set_status(app, "Connected");
|
||||
ble_send_line(app, "TT_HELLO");
|
||||
break;
|
||||
case BtStatusAdvertising:
|
||||
ble_set_status(app, "Waiting phone");
|
||||
break;
|
||||
case BtStatusOff:
|
||||
ble_set_status(app, "Bluetooth off");
|
||||
break;
|
||||
default:
|
||||
ble_set_status(app, "BLE idle");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void ble_sync_start(TagTinkerApp* app) {
|
||||
if(!app || !app->bt || app->ble_sync_active) return;
|
||||
|
||||
bt_disconnect(app->bt);
|
||||
bt_set_status_changed_callback(app->bt, bt_status_cb, app);
|
||||
app->ble_serial = bt_profile_start(app->bt, ble_profile_serial, NULL);
|
||||
if(!app->ble_serial) {
|
||||
ble_set_status(app, "BLE start fail");
|
||||
return;
|
||||
}
|
||||
|
||||
app->ble_synced_lines = 0;
|
||||
app->ble_rx_len = 0;
|
||||
app->ble_rx_line[0] = '\0';
|
||||
app->ble_rx_pending_line[0] = '\0';
|
||||
app->ble_rx_pending_ready = false;
|
||||
sync_clear_active_job(app);
|
||||
app->ble_sync_last_job_id[0] = '\0';
|
||||
app->ble_sync_last_completed_chunks = 0;
|
||||
app->ble_sync_last_compact_protocol = false;
|
||||
app->ble_sync_ready_target = -1;
|
||||
ble_profile_serial_set_event_callback(app->ble_serial, TAGTINKER_BLE_FLOW_WINDOW, ble_rx_callback, app);
|
||||
ble_profile_serial_set_rpc_active(app->ble_serial, false);
|
||||
ble_set_status(app, "Waiting phone");
|
||||
app->ble_sync_active = true;
|
||||
}
|
||||
|
||||
static void ble_sync_stop(TagTinkerApp* app) {
|
||||
if(!app || !app->ble_sync_active) return;
|
||||
|
||||
if(app->ble_sync_job_active) {
|
||||
sync_abort_active_job(app);
|
||||
}
|
||||
|
||||
bt_profile_restore_default(app->bt);
|
||||
app->ble_serial = NULL;
|
||||
app->ble_sync_active = false;
|
||||
app->ble_sync_ready_target = -1;
|
||||
}
|
||||
|
||||
static bool about_input_cb(InputEvent* event, void* context) {
|
||||
TagTinkerApp* app = context;
|
||||
if(event->type != InputTypeShort || event->key != InputKeyOk) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if(app->ble_sync_ready_target < 0 || app->ble_sync_ready_target >= app->target_count) {
|
||||
return false;
|
||||
}
|
||||
|
||||
view_dispatcher_send_custom_event(app->view_dispatcher, AboutEventSendLatestPhone);
|
||||
return true;
|
||||
snprintf(app->ble_status_text, sizeof(app->ble_status_text), "Err: %.20s", line);
|
||||
}
|
||||
|
||||
static void about_draw_cb(Canvas* canvas, void* _model) {
|
||||
AboutViewModel* model = _model;
|
||||
|
||||
canvas_set_font(canvas, FontPrimary);
|
||||
if(model->mode == 1U) {
|
||||
canvas_draw_str_aligned(canvas, 64, 8, AlignCenter, AlignTop, "Phone Sync");
|
||||
char header[32];
|
||||
snprintf(header, sizeof(header), "Custom Image %c", (model->tick % 2) ? '*' : ' ');
|
||||
canvas_draw_str_aligned(canvas, 64, 7, AlignCenter, AlignTop, header);
|
||||
canvas_set_font(canvas, FontSecondary);
|
||||
canvas_draw_str_aligned(
|
||||
canvas,
|
||||
64,
|
||||
20,
|
||||
AlignCenter,
|
||||
AlignTop,
|
||||
model->can_send_latest ? model->target_name : "Pick target on phone");
|
||||
canvas_draw_str_aligned(
|
||||
canvas,
|
||||
64,
|
||||
30,
|
||||
AlignCenter,
|
||||
AlignTop,
|
||||
model->can_send_latest ? "Press OK to send latest" : "Upload image to Flipper");
|
||||
canvas_draw_str_aligned(canvas, 64, 44, AlignCenter, AlignTop, "Status:");
|
||||
canvas_draw_str_aligned(canvas, 64, 54, AlignCenter, AlignTop, model->status_text);
|
||||
if(model->can_send_latest) {
|
||||
elements_button_center(canvas, "Send");
|
||||
canvas_draw_str_aligned(canvas, 64, 20, AlignCenter, AlignTop, "Image received!");
|
||||
canvas_draw_str_aligned(canvas, 64, 32, AlignCenter, AlignTop, model->target_name);
|
||||
canvas_draw_str_aligned(canvas, 64, 46, AlignCenter, AlignTop, "Point at tag & press OK");
|
||||
} else {
|
||||
canvas_draw_str_aligned(canvas, 64, 19, AlignCenter, AlignTop, "1. Open TagTinker app");
|
||||
canvas_draw_str_aligned(canvas, 64, 29, AlignCenter, AlignTop, "2. Connect to Flipper");
|
||||
canvas_draw_str_aligned(canvas, 64, 39, AlignCenter, AlignTop, "3. Select & send image");
|
||||
canvas_draw_str_aligned(canvas, 64, 52, AlignCenter, AlignTop, model->status_text);
|
||||
}
|
||||
if(model->can_send_latest) elements_button_center(canvas, "Send");
|
||||
} else {
|
||||
canvas_draw_str_aligned(
|
||||
canvas, 64, 10, AlignCenter, AlignTop, TAGTINKER_DISPLAY_NAME " v" TAGTINKER_VERSION);
|
||||
canvas_set_font(canvas, FontSecondary);
|
||||
canvas_draw_str_aligned(canvas, 64, 10, AlignCenter, AlignTop, TAGTINKER_DISPLAY_NAME " v" TAGTINKER_VERSION);
|
||||
canvas_draw_str_aligned(canvas, 64, 24, AlignCenter, AlignTop, "Ported by I12BP8");
|
||||
canvas_draw_str_aligned(canvas, 64, 34, AlignCenter, AlignTop, "Research by furrtek");
|
||||
}
|
||||
}
|
||||
|
||||
static bool about_input_cb(InputEvent* event, void* context) {
|
||||
TagTinkerApp* app = context;
|
||||
if(event->type != InputTypeShort || event->key != InputKeyOk) return false;
|
||||
if(app->ble_sync_ready_target < 0) return false;
|
||||
view_dispatcher_send_custom_event(app->view_dispatcher, AboutEventSendLatestPhone);
|
||||
return true;
|
||||
}
|
||||
|
||||
void tagtinker_scene_about_on_enter(void* ctx) {
|
||||
TagTinkerApp* app = ctx;
|
||||
uint32_t mode = scene_manager_get_scene_state(app->scene_manager, TagTinkerSceneAbout);
|
||||
@@ -728,21 +548,19 @@ void tagtinker_scene_about_on_enter(void* ctx) {
|
||||
}
|
||||
|
||||
AboutViewModel* model = view_get_model(app->about_view);
|
||||
if(app->ble_status_text[0] == '\0') {
|
||||
ble_set_status(app, mode == 1U ? "Waiting phone" : "Idle");
|
||||
}
|
||||
model->mode = mode;
|
||||
model->tick = 0;
|
||||
model->can_send_latest = false;
|
||||
model->target_name[0] = '\0';
|
||||
strncpy(model->status_text, app->ble_status_text, sizeof(model->status_text) - 1U);
|
||||
model->status_text[sizeof(model->status_text) - 1U] = '\0';
|
||||
model->total_rx = 0;
|
||||
memset(model->last_bytes, 0, 3);
|
||||
strncpy(model->status_text, "Init...", 31);
|
||||
|
||||
app->ble_sync_ready_target = -1; // RESET STATE
|
||||
|
||||
view_commit_model(app->about_view, true);
|
||||
|
||||
if(mode == 1U) {
|
||||
ble_sync_start(app);
|
||||
}
|
||||
|
||||
/* Delay BLE start until GUI settles */
|
||||
app->ble_sync_start_pending = (mode == 1U);
|
||||
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewAbout);
|
||||
}
|
||||
|
||||
@@ -758,54 +576,65 @@ bool tagtinker_scene_about_on_event(void* ctx, SceneManagerEvent event) {
|
||||
app->draw_x = 0;
|
||||
app->draw_y = 0;
|
||||
app->color_clear = false;
|
||||
tagtinker_prepare_bmp_tx(
|
||||
app,
|
||||
target->plid,
|
||||
image.image_path,
|
||||
image.width,
|
||||
image.height,
|
||||
image.page);
|
||||
tagtinker_prepare_bmp_tx(app, target->plid, image.image_path, image.width, image.height, image.page);
|
||||
app->tx_spam = false;
|
||||
app->ble_sync_ready_target = -1;
|
||||
/* Tear down BLE before IR transmission to prevent timing interference */
|
||||
app->ble_sync_start_pending = false;
|
||||
ble_sync_stop(app);
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if(event.type == SceneManagerEventTypeTick) {
|
||||
AboutViewModel* model = view_get_model(app->about_view);
|
||||
model->tick++;
|
||||
if(app->ble_rx_pending_ready) {
|
||||
sync_apply_line(app, app->ble_rx_pending_line);
|
||||
app->ble_rx_pending_line[0] = '\0';
|
||||
app->ble_rx_pending_ready = false;
|
||||
if(app->ble_serial) {
|
||||
ble_profile_serial_notify_buffer_is_empty(app->ble_serial);
|
||||
|
||||
/* Delayed start: Wait until 5th tick (250ms) */
|
||||
if(app->ble_sync_start_pending && model->tick >= 5) {
|
||||
app->ble_sync_start_pending = false;
|
||||
ble_sync_start(app);
|
||||
}
|
||||
|
||||
if(app->ble_sync_active) {
|
||||
if(app->ble_status == BtStatusConnected) {
|
||||
/* Serial is configured in bt_status_cb, but ensure it on tick too
|
||||
* in case connection fired before bt_status_cb was set up */
|
||||
ble_configure_serial(app);
|
||||
}
|
||||
|
||||
if(app->ble_rx_pending_ready) {
|
||||
char safe_line[1024];
|
||||
strncpy(safe_line, app->ble_rx_pending_line, 1023);
|
||||
safe_line[1023] = '\0';
|
||||
app->ble_rx_pending_line[0] = '\0';
|
||||
app->ble_rx_pending_ready = false;
|
||||
|
||||
/* Process the line FIRST (SD card write + send ACK) */
|
||||
sync_apply_line(app, safe_line);
|
||||
|
||||
/* THEN tell BLE stack we're ready for next packet.
|
||||
* Order matters: phone waits for ACK before sending next chunk,
|
||||
* so notify_buffer_is_empty here is just belt-and-suspenders. */
|
||||
if(app->ble_serial) ble_profile_serial_notify_buffer_is_empty(app->ble_serial);
|
||||
}
|
||||
|
||||
model->total_rx = app->ble_total_rx;
|
||||
memcpy(model->last_bytes, app->ble_last_bytes, 3);
|
||||
model->can_send_latest = (app->ble_sync_ready_target >= 0);
|
||||
strncpy(model->status_text, app->ble_status_text, 31);
|
||||
}
|
||||
model->can_send_latest =
|
||||
(app->ble_sync_ready_target >= 0 && app->ble_sync_ready_target < app->target_count);
|
||||
if(model->can_send_latest) {
|
||||
strncpy(
|
||||
model->target_name,
|
||||
app->targets[app->ble_sync_ready_target].name,
|
||||
sizeof(model->target_name) - 1U);
|
||||
model->target_name[sizeof(model->target_name) - 1U] = '\0';
|
||||
} else {
|
||||
model->target_name[0] = '\0';
|
||||
}
|
||||
strncpy(model->status_text, app->ble_status_text, sizeof(model->status_text) - 1U);
|
||||
model->status_text[sizeof(model->status_text) - 1U] = '\0';
|
||||
|
||||
view_commit_model(app->about_view, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void tagtinker_scene_about_on_exit(void* ctx) {
|
||||
TagTinkerApp* app = ctx;
|
||||
app->ble_sync_start_pending = false;
|
||||
ble_sync_stop(app);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
|
||||
#include "../tagtinker_app.h"
|
||||
|
||||
static void unsupported_tag_back_cb(GuiButtonType type, InputType input_type, void* ctx) {
|
||||
UNUSED(type);
|
||||
UNUSED(input_type);
|
||||
TagTinkerApp* app = ctx;
|
||||
scene_manager_previous_scene(app->scene_manager);
|
||||
}
|
||||
|
||||
static void numlock_done(void* ctx, const char* barcode) {
|
||||
TagTinkerApp* app = ctx;
|
||||
strncpy(app->barcode, barcode, TAGTINKER_BC_LEN);
|
||||
@@ -24,14 +31,14 @@ bool tagtinker_scene_barcode_input_on_event(void* ctx, SceneManagerEvent event)
|
||||
TagTinkerApp* app = ctx;
|
||||
if(event.type != SceneManagerEventTypeCustom) return false;
|
||||
|
||||
app->barcode_valid = tagtinker_barcode_to_plid(app->barcode, app->plid);
|
||||
app->barcode_valid = tagtinker_is_barcode_valid(app->barcode);
|
||||
|
||||
if(!app->barcode_valid) {
|
||||
FURI_LOG_W(TAGTINKER_TAG, "Invalid barcode: %s", app->barcode);
|
||||
|
||||
popup_reset(app->popup);
|
||||
popup_set_header(app->popup, "Invalid Barcode", 64, 20, AlignCenter, AlignCenter);
|
||||
popup_set_text(app->popup,
|
||||
"Format: Letter + 16 digits",
|
||||
"Checksum failed.\nCheck barcode digits.",
|
||||
64, 40, AlignCenter, AlignCenter);
|
||||
popup_set_timeout(app->popup, 2000);
|
||||
popup_enable_timeout(app->popup);
|
||||
@@ -40,8 +47,18 @@ bool tagtinker_scene_barcode_input_on_event(void* ctx, SceneManagerEvent event)
|
||||
return true;
|
||||
}
|
||||
|
||||
FURI_LOG_I(TAGTINKER_TAG, "Barcode: %s -> PLID %02X%02X%02X%02X",
|
||||
app->barcode, app->plid[3], app->plid[2], app->plid[1], app->plid[0]);
|
||||
TagTinkerTagProfile profile;
|
||||
if(!tagtinker_barcode_to_profile(app->barcode, &profile)) {
|
||||
|
||||
widget_reset(app->widget);
|
||||
widget_add_string_element(app->widget, 64, 10, AlignCenter, AlignTop, FontPrimary, "Unsupported Tag");
|
||||
widget_add_string_multiline_element(app->widget, 64, 30, AlignCenter, AlignTop, FontSecondary, "No profile detected\nfor this barcode.");
|
||||
widget_add_button_element(app->widget, GuiButtonTypeLeft, "Back", unsupported_tag_back_cb, app);
|
||||
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewWidget);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
app->selected_target = tagtinker_ensure_target(app, app->barcode);
|
||||
|
||||
|
||||
@@ -113,6 +113,8 @@ void tagtinker_scene_broadcast_on_enter(void* context) {
|
||||
variable_item_set_current_value_index(item, app->tx_spam ? 1 : 0);
|
||||
variable_item_set_current_value_text(item, forever_labels[app->tx_spam ? 1 : 0]);
|
||||
|
||||
variable_item_list_add(vil, ">> Transmit <<", 0, NULL, app);
|
||||
|
||||
/* OK press on any item → trigger transmit */
|
||||
variable_item_list_set_enter_callback(vil, broadcast_enter_callback, app);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ enum {
|
||||
MainMenuBroadcast,
|
||||
MainMenuTargetESL,
|
||||
MainMenuSettings,
|
||||
MainMenuAndroid,
|
||||
MainMenuPhoneSync,
|
||||
MainMenuAbout,
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ void tagtinker_scene_main_menu_on_enter(void* ctx) {
|
||||
|
||||
submenu_add_item(app->submenu, "Broadcast Payloads", MainMenuBroadcast, main_menu_cb, app);
|
||||
submenu_add_item(app->submenu, "Targeted Payloads", MainMenuTargetESL, main_menu_cb, app);
|
||||
submenu_add_item(app->submenu, "Phone Sync", MainMenuAndroid, main_menu_cb, app);
|
||||
submenu_add_item(app->submenu, "Phone Sync (Custom Images)", MainMenuPhoneSync, main_menu_cb, app);
|
||||
submenu_add_item(app->submenu, "Settings", MainMenuSettings, main_menu_cb, app);
|
||||
submenu_add_item(app->submenu, "About", MainMenuAbout, main_menu_cb, app);
|
||||
|
||||
@@ -52,8 +52,8 @@ bool tagtinker_scene_main_menu_on_event(void* ctx, SceneManagerEvent event) {
|
||||
case MainMenuSettings:
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerSceneSettings);
|
||||
return true;
|
||||
case MainMenuAndroid:
|
||||
/* state=1 tells About scene to show Android teaser */
|
||||
case MainMenuPhoneSync:
|
||||
/* state=1 tells About scene to show the browser sync receiver */
|
||||
scene_manager_set_scene_state(app->scene_manager, TagTinkerSceneAbout, 1);
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerSceneAbout);
|
||||
return true;
|
||||
|
||||
@@ -1,79 +1,54 @@
|
||||
/*
|
||||
* Text preset list.
|
||||
* Recent pushes list.
|
||||
* Only shows pushes compatible with the current tag profile.
|
||||
*/
|
||||
|
||||
#include "../tagtinker_app.h"
|
||||
#define EVT_ADD_NEW 200
|
||||
#define EVT_PRESET 0
|
||||
#define EVT_RECENT 0
|
||||
|
||||
static void presets_load(TagTinkerApp* app) {
|
||||
app->preset_count = 0;
|
||||
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
File* file = storage_file_alloc(storage);
|
||||
|
||||
if(storage_file_open(file, APP_DATA_PATH("presets.txt"),
|
||||
FSAM_READ, FSOM_OPEN_EXISTING)) {
|
||||
char buf[512];
|
||||
uint16_t read = storage_file_read(file, buf, sizeof(buf) - 1);
|
||||
buf[read] = '\0';
|
||||
storage_file_close(file);
|
||||
|
||||
char* line = buf;
|
||||
while(line && *line && app->preset_count < TAGTINKER_MAX_PRESETS) {
|
||||
char* nl = strchr(line, '\n');
|
||||
if(nl) *nl = '\0';
|
||||
|
||||
unsigned w, h, pg, inv, clr;
|
||||
if(sscanf(line, "%u|%u|%u|%u|%u|", &w, &h, &pg, &inv, &clr) == 5) {
|
||||
char* p = line;
|
||||
int pipes = 0;
|
||||
while(*p && pipes < 5) { if(*p == '|') pipes++; p++; }
|
||||
|
||||
uint8_t idx = app->preset_count++;
|
||||
app->presets[idx].width = (uint16_t)w;
|
||||
app->presets[idx].height = (uint16_t)h;
|
||||
app->presets[idx].page = (uint8_t)pg;
|
||||
app->presets[idx].invert = (inv != 0);
|
||||
app->presets[idx].color_clear = (clr != 0);
|
||||
memset(app->presets[idx].text, 0, TAGTINKER_PRESET_TEXT_LEN);
|
||||
if(pipes == 5) {
|
||||
strncpy(app->presets[idx].text, p, TAGTINKER_PRESET_TEXT_LEN - 1);
|
||||
}
|
||||
}
|
||||
line = nl ? nl + 1 : NULL;
|
||||
}
|
||||
}
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
}
|
||||
|
||||
static void preset_list_cb(void* ctx, uint32_t index) {
|
||||
static void recent_list_cb(void* ctx, uint32_t index) {
|
||||
TagTinkerApp* app = ctx;
|
||||
view_dispatcher_send_custom_event(app->view_dispatcher, index);
|
||||
}
|
||||
|
||||
static char preset_labels[TAGTINKER_MAX_PRESETS][48];
|
||||
static char recent_labels[TAGTINKER_MAX_PRESETS][48];
|
||||
static uint8_t filtered_indices[TAGTINKER_MAX_PRESETS];
|
||||
static uint8_t filtered_count = 0;
|
||||
|
||||
void tagtinker_scene_preset_list_on_enter(void* ctx) {
|
||||
TagTinkerApp* app = ctx;
|
||||
|
||||
presets_load(app);
|
||||
tagtinker_recents_load(app);
|
||||
|
||||
submenu_reset(app->submenu);
|
||||
submenu_set_header(app->submenu, "Text Presets");
|
||||
submenu_set_header(app->submenu, "Recent Pushes");
|
||||
|
||||
submenu_add_item(app->submenu, "[+] New Preset",
|
||||
EVT_ADD_NEW, preset_list_cb, app);
|
||||
submenu_add_item(app->submenu, "[+] New Text",
|
||||
EVT_ADD_NEW, recent_list_cb, app);
|
||||
|
||||
for(uint8_t i = 0; i < app->preset_count; i++) {
|
||||
snprintf(preset_labels[i], sizeof(preset_labels[i]),
|
||||
"%ux%u \"%s\"",
|
||||
app->presets[i].width,
|
||||
app->presets[i].height,
|
||||
app->presets[i].text);
|
||||
submenu_add_item(app->submenu, preset_labels[i],
|
||||
EVT_PRESET + i, preset_list_cb, app);
|
||||
filtered_count = 0;
|
||||
for(uint8_t i = 0; i < app->recent_count; i++) {
|
||||
/* Filter by current tag's width/height if available */
|
||||
if(app->selected_target >= 0) {
|
||||
TagTinkerTarget* target = &app->targets[app->selected_target];
|
||||
if(target->profile.width > 0 && target->profile.height > 0) {
|
||||
if(app->recents[i].width != target->profile.width ||
|
||||
app->recents[i].height != target->profile.height) {
|
||||
continue; /* Incompatible size, skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filtered_indices[filtered_count] = i;
|
||||
snprintf(recent_labels[filtered_count], sizeof(recent_labels[filtered_count]),
|
||||
"\"%s\"",
|
||||
app->recents[i].text);
|
||||
|
||||
submenu_add_item(app->submenu, recent_labels[filtered_count],
|
||||
EVT_RECENT + filtered_count, recent_list_cb, app);
|
||||
|
||||
filtered_count++;
|
||||
}
|
||||
|
||||
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu);
|
||||
@@ -90,20 +65,27 @@ bool tagtinker_scene_preset_list_on_event(void* ctx, SceneManagerEvent event) {
|
||||
return true;
|
||||
}
|
||||
|
||||
uint32_t idx = event.event - EVT_PRESET;
|
||||
if(idx < app->preset_count) {
|
||||
app->esl_width = app->presets[idx].width;
|
||||
app->esl_height = app->presets[idx].height;
|
||||
app->img_page = app->presets[idx].page;
|
||||
app->invert_text = app->presets[idx].invert;
|
||||
app->color_clear = app->presets[idx].color_clear;
|
||||
strncpy(app->text_input_buf, app->presets[idx].text,
|
||||
uint32_t f_idx = event.event - EVT_RECENT;
|
||||
if(f_idx < filtered_count) {
|
||||
uint8_t r_idx = filtered_indices[f_idx];
|
||||
|
||||
app->esl_width = app->recents[r_idx].width;
|
||||
app->esl_height = app->recents[r_idx].height;
|
||||
app->img_page = app->recents[r_idx].page;
|
||||
app->invert_text = app->recents[r_idx].invert;
|
||||
app->color_clear = app->recents[r_idx].color_clear;
|
||||
app->text_padding_pct = app->recents[r_idx].padding;
|
||||
app->signal_mode = TagTinkerSignalPP4;
|
||||
strncpy(app->text_input_buf, app->recents[r_idx].text,
|
||||
sizeof(app->text_input_buf) - 1);
|
||||
|
||||
FURI_LOG_I(TAGTINKER_TAG, "Preset %lu: %ux%u \"%s\"",
|
||||
idx, app->esl_width, app->esl_height, app->text_input_buf);
|
||||
|
||||
TagTinkerTarget* target = &app->targets[app->selected_target];
|
||||
|
||||
/* Auto-save/update recents order */
|
||||
tagtinker_recents_add(app, app->text_input_buf);
|
||||
|
||||
FURI_LOG_I(TAGTINKER_TAG, "TX Recent: reps=%u", app->data_frame_repeats);
|
||||
|
||||
tagtinker_prepare_text_tx(app, target->plid);
|
||||
app->tx_spam = false;
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
|
||||
|
||||
@@ -6,42 +6,11 @@
|
||||
|
||||
enum {
|
||||
SettingsItemStartupWarning,
|
||||
SettingsItemSignalMode,
|
||||
SettingsItemCompression,
|
||||
SettingsItemFrameRepeat,
|
||||
SettingsItemOffsetX,
|
||||
SettingsItemOffsetY,
|
||||
SettingsItemClearRecents,
|
||||
};
|
||||
|
||||
static const char* settings_toggle_labels[] = {"Off", "On"};
|
||||
static const char* settings_signal_labels[] = {"PP4", "PP16"};
|
||||
static const char* settings_compression_labels[] = {"Auto", "Raw", "RLE"};
|
||||
static const uint16_t settings_coord_values[] = {
|
||||
0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128,
|
||||
136, 144, 152, 160, 168, 176, 184, 192, 200, 208, 216, 224, 232, 240, 248, 256, 264,
|
||||
272, 280, 288, 296, 304, 312, 320, 328, 336, 344, 352, 360, 368, 376, 384, 392, 400,
|
||||
408, 416, 424, 432, 440, 448, 456, 464, 472, 480, 488, 496, 504, 512, 520, 528, 536,
|
||||
544, 552, 560, 568, 576, 584, 592, 600, 608, 616, 624, 632, 640, 648, 656, 664, 672,
|
||||
680, 688, 696, 704, 712, 720, 728, 736, 744, 752, 760, 768, 776, 784, 792, 800,
|
||||
};
|
||||
|
||||
#define SETTINGS_COORD_COUNT COUNT_OF(settings_coord_values)
|
||||
|
||||
static uint8_t nearest_coord_index(uint16_t value) {
|
||||
uint8_t best_idx = 0;
|
||||
uint16_t best_diff = UINT16_MAX;
|
||||
|
||||
for(uint8_t i = 0; i < SETTINGS_COORD_COUNT; i++) {
|
||||
uint16_t current = settings_coord_values[i];
|
||||
uint16_t diff = (current > value) ? (current - value) : (value - current);
|
||||
if(diff < best_diff) {
|
||||
best_diff = diff;
|
||||
best_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
return best_idx;
|
||||
}
|
||||
|
||||
static void startup_warning_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
@@ -50,35 +19,7 @@ static void startup_warning_changed(VariableItem* item) {
|
||||
app->show_startup_warning = (index == 1);
|
||||
variable_item_set_current_value_text(item, settings_toggle_labels[index]);
|
||||
|
||||
if(!tagtinker_settings_save(app)) {
|
||||
FURI_LOG_W(TAGTINKER_TAG, "Failed to save settings");
|
||||
}
|
||||
}
|
||||
|
||||
static void signal_mode_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
uint8_t index = variable_item_get_current_value_index(item);
|
||||
|
||||
if(index > TagTinkerSignalPP16) index = TagTinkerSignalPP16;
|
||||
app->signal_mode = (TagTinkerSignalMode)index;
|
||||
variable_item_set_current_value_text(item, settings_signal_labels[index]);
|
||||
|
||||
if(!tagtinker_settings_save(app)) {
|
||||
FURI_LOG_W(TAGTINKER_TAG, "Failed to save settings");
|
||||
}
|
||||
}
|
||||
|
||||
static void compression_mode_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
uint8_t index = variable_item_get_current_value_index(item);
|
||||
|
||||
if(index > TagTinkerCompressionRle) index = TagTinkerCompressionRle;
|
||||
app->compression_mode = (TagTinkerCompressionMode)index;
|
||||
variable_item_set_current_value_text(item, settings_compression_labels[index]);
|
||||
|
||||
if(!tagtinker_settings_save(app)) {
|
||||
FURI_LOG_W(TAGTINKER_TAG, "Failed to save settings");
|
||||
}
|
||||
tagtinker_settings_save(app);
|
||||
}
|
||||
|
||||
static void frame_repeat_changed(VariableItem* item) {
|
||||
@@ -91,39 +32,14 @@ static void frame_repeat_changed(VariableItem* item) {
|
||||
snprintf(buf, sizeof(buf), "%u", app->data_frame_repeats);
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
|
||||
if(!tagtinker_settings_save(app)) {
|
||||
FURI_LOG_W(TAGTINKER_TAG, "Failed to save settings");
|
||||
}
|
||||
tagtinker_settings_save(app);
|
||||
}
|
||||
|
||||
static void offset_x_changed(VariableItem* item) {
|
||||
static void clear_recents_cb(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
uint8_t index = variable_item_get_current_value_index(item);
|
||||
if(index >= SETTINGS_COORD_COUNT) index = SETTINGS_COORD_COUNT - 1U;
|
||||
app->draw_x = settings_coord_values[index];
|
||||
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->draw_x);
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
|
||||
if(!tagtinker_settings_save(app)) {
|
||||
FURI_LOG_W(TAGTINKER_TAG, "Failed to save settings");
|
||||
}
|
||||
}
|
||||
|
||||
static void offset_y_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
uint8_t index = variable_item_get_current_value_index(item);
|
||||
if(index >= SETTINGS_COORD_COUNT) index = SETTINGS_COORD_COUNT - 1U;
|
||||
app->draw_y = settings_coord_values[index];
|
||||
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->draw_y);
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
|
||||
if(!tagtinker_settings_save(app)) {
|
||||
FURI_LOG_W(TAGTINKER_TAG, "Failed to save settings");
|
||||
}
|
||||
app->recent_count = 0;
|
||||
tagtinker_recents_save(app);
|
||||
variable_item_set_current_value_text(item, "Cleared!");
|
||||
}
|
||||
|
||||
void tagtinker_scene_settings_on_enter(void* ctx) {
|
||||
@@ -138,15 +54,7 @@ void tagtinker_scene_settings_on_enter(void* ctx) {
|
||||
variable_item_set_current_value_text(
|
||||
item, settings_toggle_labels[app->show_startup_warning ? 1 : 0]);
|
||||
|
||||
item = variable_item_list_add(list, "Signal Mode", 2, signal_mode_changed, app);
|
||||
variable_item_set_current_value_index(item, app->signal_mode);
|
||||
variable_item_set_current_value_text(item, settings_signal_labels[app->signal_mode]);
|
||||
|
||||
item = variable_item_list_add(list, "Compression", 3, compression_mode_changed, app);
|
||||
variable_item_set_current_value_index(item, app->compression_mode);
|
||||
variable_item_set_current_value_text(item, settings_compression_labels[app->compression_mode]);
|
||||
|
||||
item = variable_item_list_add(list, "Frame Repeat", 5, frame_repeat_changed, app);
|
||||
item = variable_item_list_add(list, "Frame Repeat", 10, frame_repeat_changed, app);
|
||||
variable_item_set_current_value_index(item, app->data_frame_repeats - 1U);
|
||||
{
|
||||
char buf[4];
|
||||
@@ -154,21 +62,8 @@ void tagtinker_scene_settings_on_enter(void* ctx) {
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
}
|
||||
|
||||
item = variable_item_list_add(list, "Offset X", SETTINGS_COORD_COUNT, offset_x_changed, app);
|
||||
variable_item_set_current_value_index(item, nearest_coord_index(app->draw_x));
|
||||
{
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->draw_x);
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
}
|
||||
|
||||
item = variable_item_list_add(list, "Offset Y", SETTINGS_COORD_COUNT, offset_y_changed, app);
|
||||
variable_item_set_current_value_index(item, nearest_coord_index(app->draw_y));
|
||||
{
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->draw_y);
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
}
|
||||
item = variable_item_list_add(list, "Clear Recents", 0, clear_recents_cb, app);
|
||||
variable_item_set_current_value_text(item, "");
|
||||
|
||||
variable_item_list_set_selected_item(list, SettingsItemStartupWarning);
|
||||
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewVarItemList);
|
||||
|
||||
@@ -1,113 +1,28 @@
|
||||
/*
|
||||
* Size picker scene.
|
||||
* Size picker scene - streamlined for "Set Text" flow.
|
||||
* Only keeps Page, Polarity, Padding, and Transmit.
|
||||
*/
|
||||
|
||||
#include "../tagtinker_app.h"
|
||||
#include "../views/tagtinker_font.h"
|
||||
#include "../protocol/tagtinker_proto.h"
|
||||
#include <storage/storage.h>
|
||||
|
||||
static const uint16_t width_values[] = {
|
||||
48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128, 136, 144, 152, 160, 168, 172,
|
||||
176, 184, 192, 200, 208, 224, 240, 256, 264, 272, 288, 296, 304, 320, 400, 648, 800,
|
||||
};
|
||||
|
||||
static const uint16_t height_values[] = {
|
||||
50, 55, 60, 65, 70, 72, 75, 80, 85, 90, 96, 104, 112, 120, 128, 140, 152, 176, 192, 300, 480,
|
||||
};
|
||||
|
||||
static const uint16_t coord_values[] = {
|
||||
0, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128,
|
||||
136, 144, 152, 160, 168, 176, 184, 192, 200, 208, 216, 224, 232, 240, 248, 256, 264,
|
||||
272, 280, 288, 296, 304, 312, 320, 328, 336, 344, 352, 360, 368, 376, 384, 392, 400,
|
||||
408, 416, 424, 432, 440, 448, 456, 464, 472, 480, 488, 496, 504, 512, 520, 528, 536,
|
||||
544, 552, 560, 568, 576, 584, 592, 600, 608, 616, 624, 632, 640, 648, 656, 664, 672,
|
||||
680, 688, 696, 704, 712, 720, 728, 736, 744, 752, 760, 768, 776, 784, 792, 800,
|
||||
};
|
||||
|
||||
static const char* compression_labels[] = {"Auto", "Raw", "RLE"};
|
||||
|
||||
#define W_COUNT COUNT_OF(width_values)
|
||||
#define H_COUNT COUNT_OF(height_values)
|
||||
#define COORD_COUNT COUNT_OF(coord_values)
|
||||
|
||||
enum {
|
||||
SettingWidth,
|
||||
SettingHeight,
|
||||
SettingPage,
|
||||
SettingPolarity,
|
||||
SettingMode,
|
||||
SettingCompression,
|
||||
SettingFrameRepeat,
|
||||
SettingOffsetX,
|
||||
SettingOffsetY,
|
||||
SettingSave,
|
||||
SettingPadding,
|
||||
SettingTransmit,
|
||||
};
|
||||
|
||||
static void clamp_current_offsets(TagTinkerApp* app);
|
||||
|
||||
static void width_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
uint8_t idx = variable_item_get_current_value_index(item);
|
||||
if(idx >= W_COUNT) idx = W_COUNT - 1;
|
||||
app->esl_width = width_values[idx];
|
||||
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->esl_width);
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
clamp_current_offsets(app);
|
||||
}
|
||||
|
||||
static void height_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
uint8_t idx = variable_item_get_current_value_index(item);
|
||||
if(idx >= H_COUNT) idx = H_COUNT - 1;
|
||||
app->esl_height = height_values[idx];
|
||||
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->esl_height);
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
clamp_current_offsets(app);
|
||||
}
|
||||
|
||||
static uint8_t nearest_value_index(const uint16_t* values, uint8_t count, uint16_t target) {
|
||||
uint8_t best_idx = 0;
|
||||
uint16_t best_diff = UINT16_MAX;
|
||||
|
||||
for(uint8_t i = 0; i < count; i++) {
|
||||
uint16_t value = values[i];
|
||||
uint16_t diff = (value > target) ? (value - target) : (target - value);
|
||||
if(diff < best_diff) {
|
||||
best_diff = diff;
|
||||
best_idx = i;
|
||||
}
|
||||
}
|
||||
|
||||
return best_idx;
|
||||
}
|
||||
|
||||
static void clamp_current_offsets(TagTinkerApp* app) {
|
||||
if(!app) return;
|
||||
if(app->selected_target < 0 || app->selected_target >= app->target_count) return;
|
||||
|
||||
const TagTinkerTarget* target = &app->targets[app->selected_target];
|
||||
if(!target->profile.known || !target->profile.width || !target->profile.height) return;
|
||||
|
||||
uint16_t max_x = (app->esl_width < target->profile.width) ?
|
||||
(uint16_t)(target->profile.width - app->esl_width) :
|
||||
0U;
|
||||
uint16_t max_y = (app->esl_height < target->profile.height) ?
|
||||
(uint16_t)(target->profile.height - app->esl_height) :
|
||||
0U;
|
||||
|
||||
if(app->draw_x > max_x) app->draw_x = max_x;
|
||||
if(app->draw_y > max_y) app->draw_y = max_y;
|
||||
static uint8_t text_page_to_index(uint8_t page) {
|
||||
if(page == 0U) return 0U;
|
||||
if(page > 8U) return 7U;
|
||||
return (uint8_t)(page - 1U);
|
||||
}
|
||||
|
||||
static void page_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
app->img_page = variable_item_get_current_value_index(item);
|
||||
app->img_page = (uint8_t)(variable_item_get_current_value_index(item) + 1U);
|
||||
|
||||
char buf[4];
|
||||
snprintf(buf, sizeof(buf), "%u", app->img_page);
|
||||
@@ -122,112 +37,37 @@ static void polarity_changed(VariableItem* item) {
|
||||
item, app->invert_text ? "W on B" : "B on W");
|
||||
}
|
||||
|
||||
static void mode_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
TagTinkerTarget* target = (app->selected_target >= 0) ? &app->targets[app->selected_target] : NULL;
|
||||
bool accent = tagtinker_target_supports_accent(target);
|
||||
app->color_clear = (variable_item_get_current_value_index(item) == 1);
|
||||
|
||||
variable_item_set_current_value_text(
|
||||
item,
|
||||
accent ? (app->color_clear ? "Accent" : "Black")
|
||||
: (app->color_clear ? "Dual Plane" : "Mono Fast"));
|
||||
}
|
||||
|
||||
static void compression_changed(VariableItem* item) {
|
||||
static void padding_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
uint8_t index = variable_item_get_current_value_index(item);
|
||||
if(index > TagTinkerCompressionRle) index = TagTinkerCompressionRle;
|
||||
app->compression_mode = (TagTinkerCompressionMode)index;
|
||||
variable_item_set_current_value_text(item, compression_labels[index]);
|
||||
}
|
||||
|
||||
static void frame_repeat_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
app->data_frame_repeats = variable_item_get_current_value_index(item) + 1U;
|
||||
|
||||
char buf[4];
|
||||
snprintf(buf, sizeof(buf), "%u", app->data_frame_repeats);
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
}
|
||||
|
||||
static void offset_x_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
uint8_t index = variable_item_get_current_value_index(item);
|
||||
if(index >= COORD_COUNT) index = COORD_COUNT - 1U;
|
||||
app->draw_x = coord_values[index];
|
||||
clamp_current_offsets(app);
|
||||
app->text_padding_pct = index * 5; /* 0, 5, 10, 15, 20, 25, 30, 35, 40 % */
|
||||
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->draw_x);
|
||||
snprintf(buf, sizeof(buf), "%u%%", app->text_padding_pct);
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
}
|
||||
|
||||
static void offset_y_changed(VariableItem* item) {
|
||||
TagTinkerApp* app = variable_item_get_context(item);
|
||||
uint8_t index = variable_item_get_current_value_index(item);
|
||||
if(index >= COORD_COUNT) index = COORD_COUNT - 1U;
|
||||
app->draw_y = coord_values[index];
|
||||
clamp_current_offsets(app);
|
||||
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->draw_y);
|
||||
variable_item_set_current_value_text(item, buf);
|
||||
}
|
||||
|
||||
static void save_presets_to_sd(TagTinkerApp* app) {
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
storage_common_mkdir(storage, APP_DATA_PATH(""));
|
||||
|
||||
File* file = storage_file_alloc(storage);
|
||||
if(storage_file_open(file, APP_DATA_PATH("presets.txt"),
|
||||
FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
|
||||
for(uint8_t i = 0; i < app->preset_count; i++) {
|
||||
char line[80];
|
||||
int len = snprintf(line, sizeof(line), "%u|%u|%u|%d|%d|%s\n",
|
||||
app->presets[i].width, app->presets[i].height,
|
||||
app->presets[i].page,
|
||||
app->presets[i].invert ? 1 : 0,
|
||||
app->presets[i].color_clear ? 1 : 0,
|
||||
app->presets[i].text);
|
||||
storage_file_write(file, line, (uint16_t)len);
|
||||
}
|
||||
storage_file_close(file);
|
||||
}
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
}
|
||||
|
||||
static void setting_cb(void* ctx, uint32_t index) {
|
||||
TagTinkerApp* app = ctx;
|
||||
|
||||
if(index == SettingSave) {
|
||||
/* Save current settings as a preset */
|
||||
if(app->preset_count < TAGTINKER_MAX_PRESETS) {
|
||||
uint8_t idx = app->preset_count++;
|
||||
app->presets[idx].width = app->esl_width;
|
||||
app->presets[idx].height = app->esl_height;
|
||||
app->presets[idx].page = app->img_page;
|
||||
app->presets[idx].invert = app->invert_text;
|
||||
app->presets[idx].color_clear = app->color_clear;
|
||||
strncpy(app->presets[idx].text, app->text_input_buf,
|
||||
TAGTINKER_PRESET_TEXT_LEN - 1);
|
||||
app->presets[idx].text[TAGTINKER_PRESET_TEXT_LEN - 1] = '\0';
|
||||
|
||||
save_presets_to_sd(app);
|
||||
notification_message(app->notifications, &sequence_success);
|
||||
FURI_LOG_I(TAGTINKER_TAG, "Preset saved: %ux%u", app->esl_width, app->esl_height);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if(index != SettingTransmit) return;
|
||||
|
||||
FURI_LOG_I(TAGTINKER_TAG, "TX: %ux%u pg=%u inv=%d clr=%d",
|
||||
app->esl_width, app->esl_height, app->img_page,
|
||||
app->invert_text, app->color_clear);
|
||||
|
||||
TagTinkerTarget* target = &app->targets[app->selected_target];
|
||||
|
||||
/* Auto-set best settings based on profile */
|
||||
app->esl_width = target->profile.width;
|
||||
app->esl_height = target->profile.height;
|
||||
/* Default dot matrix to RLE if supported? PricehaxBT does this automatically. */
|
||||
app->compression_mode = TagTinkerCompressionAuto;
|
||||
app->signal_mode = TagTinkerSignalPP4;
|
||||
|
||||
FURI_LOG_I(TAGTINKER_TAG, "TX: %ux%u pg=%u inv=%d pad=%u%% reps=%u",
|
||||
app->esl_width, app->esl_height, app->img_page, app->invert_text,
|
||||
app->text_padding_pct, app->data_frame_repeats);
|
||||
|
||||
/* Auto-save to recents */
|
||||
tagtinker_recents_add(app, app->text_input_buf);
|
||||
|
||||
tagtinker_prepare_text_tx(app, target->plid);
|
||||
app->tx_spam = false;
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
|
||||
@@ -235,39 +75,16 @@ static void setting_cb(void* ctx, uint32_t index) {
|
||||
|
||||
void tagtinker_scene_size_picker_on_enter(void* ctx) {
|
||||
TagTinkerApp* app = ctx;
|
||||
app->signal_mode = TagTinkerSignalPP4;
|
||||
if(app->img_page == 0U) app->img_page = 1U;
|
||||
if(app->img_page > 8U) app->img_page = 8U;
|
||||
|
||||
variable_item_list_reset(app->var_item_list);
|
||||
|
||||
uint8_t w_idx = nearest_value_index(width_values, W_COUNT, app->esl_width);
|
||||
uint8_t h_idx = nearest_value_index(height_values, H_COUNT, app->esl_height);
|
||||
app->esl_width = width_values[w_idx];
|
||||
app->esl_height = height_values[h_idx];
|
||||
clamp_current_offsets(app);
|
||||
|
||||
/* Width */
|
||||
VariableItem* item_w = variable_item_list_add(
|
||||
app->var_item_list, "Width", W_COUNT, width_changed, app);
|
||||
variable_item_set_current_value_index(item_w, w_idx);
|
||||
{
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->esl_width);
|
||||
variable_item_set_current_value_text(item_w, buf);
|
||||
}
|
||||
|
||||
/* Height */
|
||||
VariableItem* item_h = variable_item_list_add(
|
||||
app->var_item_list, "Height", H_COUNT, height_changed, app);
|
||||
variable_item_set_current_value_index(item_h, h_idx);
|
||||
{
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->esl_height);
|
||||
variable_item_set_current_value_text(item_h, buf);
|
||||
}
|
||||
|
||||
/* Page */
|
||||
VariableItem* item_pg = variable_item_list_add(
|
||||
app->var_item_list, "Page", 8, page_changed, app);
|
||||
variable_item_set_current_value_index(item_pg, app->img_page);
|
||||
variable_item_set_current_value_index(item_pg, text_page_to_index(app->img_page));
|
||||
{
|
||||
char buf[4];
|
||||
snprintf(buf, sizeof(buf), "%u", app->img_page);
|
||||
@@ -281,54 +98,16 @@ void tagtinker_scene_size_picker_on_enter(void* ctx) {
|
||||
variable_item_set_current_value_text(
|
||||
item_col, app->invert_text ? "W on B" : "B on W");
|
||||
|
||||
/* Text ink / plane mode */
|
||||
TagTinkerTarget* target = (app->selected_target >= 0) ? &app->targets[app->selected_target] : NULL;
|
||||
bool accent = tagtinker_target_supports_accent(target);
|
||||
VariableItem* item_mode = variable_item_list_add(
|
||||
app->var_item_list, accent ? "Ink" : "Mode", 2, mode_changed, app);
|
||||
variable_item_set_current_value_index(item_mode, app->color_clear ? 1 : 0);
|
||||
variable_item_set_current_value_text(
|
||||
item_mode,
|
||||
accent ? (app->color_clear ? "Accent" : "Black")
|
||||
: (app->color_clear ? "Dual Plane" : "Mono Fast"));
|
||||
|
||||
VariableItem* item_comp = variable_item_list_add(
|
||||
app->var_item_list, "Compression", 3, compression_changed, app);
|
||||
variable_item_set_current_value_index(item_comp, app->compression_mode);
|
||||
variable_item_set_current_value_text(item_comp, compression_labels[app->compression_mode]);
|
||||
|
||||
VariableItem* item_rep = variable_item_list_add(
|
||||
app->var_item_list, "Frame Repeat", 5, frame_repeat_changed, app);
|
||||
variable_item_set_current_value_index(item_rep, app->data_frame_repeats - 1U);
|
||||
{
|
||||
char buf[4];
|
||||
snprintf(buf, sizeof(buf), "%u", app->data_frame_repeats);
|
||||
variable_item_set_current_value_text(item_rep, buf);
|
||||
}
|
||||
|
||||
VariableItem* item_x = variable_item_list_add(
|
||||
app->var_item_list, "Offset X", COORD_COUNT, offset_x_changed, app);
|
||||
variable_item_set_current_value_index(
|
||||
item_x, nearest_value_index(coord_values, COORD_COUNT, app->draw_x));
|
||||
/* Padding */
|
||||
VariableItem* item_pad = variable_item_list_add(
|
||||
app->var_item_list, "Padding", 9, padding_changed, app);
|
||||
variable_item_set_current_value_index(item_pad, app->text_padding_pct / 5);
|
||||
{
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->draw_x);
|
||||
variable_item_set_current_value_text(item_x, buf);
|
||||
snprintf(buf, sizeof(buf), "%u%%", app->text_padding_pct);
|
||||
variable_item_set_current_value_text(item_pad, buf);
|
||||
}
|
||||
|
||||
VariableItem* item_y = variable_item_list_add(
|
||||
app->var_item_list, "Offset Y", COORD_COUNT, offset_y_changed, app);
|
||||
variable_item_set_current_value_index(
|
||||
item_y, nearest_value_index(coord_values, COORD_COUNT, app->draw_y));
|
||||
{
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%u", app->draw_y);
|
||||
variable_item_set_current_value_text(item_y, buf);
|
||||
}
|
||||
|
||||
/* Save preset button */
|
||||
variable_item_list_add(app->var_item_list, "[*] Save Preset", 0, NULL, app);
|
||||
|
||||
/* Transmit button */
|
||||
variable_item_list_add(app->var_item_list, ">> Send to Tag <<", 0, NULL, app);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
*/
|
||||
|
||||
#include "../tagtinker_app.h"
|
||||
#include <dialogs/dialogs.h>
|
||||
|
||||
static void target_actions_cb(void* ctx, uint32_t index) {
|
||||
TagTinkerApp* app = ctx;
|
||||
@@ -22,49 +21,34 @@ static bool confirm_target_action(TagTinkerApp* app, const char* header, const c
|
||||
return button == DialogMessageButtonRight;
|
||||
}
|
||||
|
||||
static void show_target_action_result(TagTinkerApp* app, const char* header, const char* body) {
|
||||
if(!app || !header || !body) return;
|
||||
|
||||
DialogMessage* message = dialog_message_alloc();
|
||||
dialog_message_set_header(message, header, 64, 2, AlignCenter, AlignTop);
|
||||
dialog_message_set_text(message, body, 64, 18, AlignCenter, AlignTop);
|
||||
dialog_message_set_buttons(message, "OK", NULL, NULL);
|
||||
dialog_message_show(app->dialogs, message);
|
||||
dialog_message_free(message);
|
||||
}
|
||||
|
||||
static void show_target_details(TagTinkerApp* app, const TagTinkerTarget* target) {
|
||||
if(!target) return;
|
||||
|
||||
char body[160];
|
||||
if(target->profile.known && target->profile.width && target->profile.height) {
|
||||
snprintf(
|
||||
body,
|
||||
sizeof(body),
|
||||
"Type: %u\nKind: %s\nSize: %ux%u\nColor: %s",
|
||||
target->profile.type_code,
|
||||
tagtinker_profile_kind_label(target->profile.kind),
|
||||
target->profile.width,
|
||||
target->profile.height,
|
||||
tagtinker_profile_color_label(target->profile.color));
|
||||
} else if(target->profile.known) {
|
||||
snprintf(
|
||||
body,
|
||||
sizeof(body),
|
||||
"Type: %u\nKind: %s\nColor: %s",
|
||||
target->profile.type_code,
|
||||
tagtinker_profile_kind_label(target->profile.kind),
|
||||
tagtinker_profile_color_label(target->profile.color));
|
||||
} else {
|
||||
snprintf(body, sizeof(body), "Type: %u\nProfile: Unknown", target->profile.type_code);
|
||||
}
|
||||
text_box_reset(app->text_box);
|
||||
text_box_set_font(app->text_box, TextBoxFontText);
|
||||
text_box_set_focus(app->text_box, TextBoxFocusStart);
|
||||
|
||||
DialogMessage* message = dialog_message_alloc();
|
||||
dialog_message_set_header(message, "Tag Info", 64, 2, AlignCenter, AlignTop);
|
||||
dialog_message_set_text(message, body, 64, 18, AlignCenter, AlignTop);
|
||||
dialog_message_set_buttons(message, "OK", NULL, NULL);
|
||||
dialog_message_show(app->dialogs, message);
|
||||
dialog_message_free(message);
|
||||
static char details_buf[256];
|
||||
snprintf(
|
||||
details_buf,
|
||||
sizeof(details_buf),
|
||||
"--- Tag Info ---\n"
|
||||
"Model: %s\n"
|
||||
"Type: %u (%s)\n"
|
||||
"Size: %ux%u\n"
|
||||
"Color: %s\n"
|
||||
"Barcode:\n%s",
|
||||
target->profile.model_name ? target->profile.model_name : "Unknown",
|
||||
target->profile.type_code,
|
||||
tagtinker_profile_kind_label(target->profile.kind),
|
||||
target->profile.width,
|
||||
target->profile.height,
|
||||
tagtinker_profile_color_label(target->profile.color),
|
||||
target->barcode);
|
||||
|
||||
text_box_set_text(app->text_box, details_buf);
|
||||
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTextBox);
|
||||
}
|
||||
|
||||
void tagtinker_scene_target_actions_on_enter(void* ctx) {
|
||||
@@ -86,12 +70,9 @@ void tagtinker_scene_target_actions_on_enter(void* ctx) {
|
||||
|
||||
if(allow_graphics) {
|
||||
submenu_add_item(app->submenu, "Set Text", TagTinkerTargetPushText, target_actions_cb, app);
|
||||
submenu_add_item(app->submenu, "Set Image", TagTinkerTargetPushSyncedImage, target_actions_cb, app);
|
||||
}
|
||||
|
||||
submenu_add_item(app->submenu, "LED Test", TagTinkerTargetPingFlash, target_actions_cb, app);
|
||||
submenu_add_item(
|
||||
app->submenu, "Delete Saved Images", TagTinkerTargetDeleteSyncedImages, target_actions_cb, app);
|
||||
submenu_add_item(app->submenu, "Delete Tag", TagTinkerTargetDeleteTag, target_actions_cb, app);
|
||||
|
||||
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewSubmenu);
|
||||
@@ -114,25 +95,6 @@ bool tagtinker_scene_target_actions_on_event(void* ctx, SceneManagerEvent event)
|
||||
if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true;
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerScenePresetList);
|
||||
return true;
|
||||
case TagTinkerTargetPushSyncedImage:
|
||||
if(!tagtinker_target_supports_graphics(&app->targets[app->selected_target])) return true;
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerSceneSyncedImageList);
|
||||
return true;
|
||||
case TagTinkerTargetDeleteSyncedImages:
|
||||
{
|
||||
TagTinkerTarget* target = &app->targets[app->selected_target];
|
||||
char body[96];
|
||||
snprintf(body, sizeof(body), "Remove saved images for\n%s?", target->name);
|
||||
if(!confirm_target_action(app, "Delete Images", body, "Delete")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
size_t removed = tagtinker_delete_synced_images_for_barcode(app, target->barcode);
|
||||
char result[96];
|
||||
snprintf(result, sizeof(result), "Removed %u saved image%s", (unsigned)removed, removed == 1U ? "" : "s");
|
||||
show_target_action_result(app, "Delete Images", result);
|
||||
}
|
||||
return true;
|
||||
case TagTinkerTargetPingFlash:
|
||||
{
|
||||
TagTinkerTarget* target = &app->targets[app->selected_target];
|
||||
@@ -144,13 +106,14 @@ bool tagtinker_scene_target_actions_on_event(void* ctx, SceneManagerEvent event)
|
||||
|
||||
app->frame_sequence[0] = malloc(TAGTINKER_MAX_FRAME_SIZE);
|
||||
app->frame_lengths[0] = tagtinker_make_ping_frame(app->frame_sequence[0], target->plid);
|
||||
app->frame_repeats[0] = 500;
|
||||
app->frame_repeats[0] = 160;
|
||||
|
||||
app->frame_sequence[1] = malloc(TAGTINKER_MAX_FRAME_SIZE);
|
||||
const uint8_t blink_payload[6] = {0x06, 0xC9, 0x00, 0x00, 0x00, 0x00};
|
||||
/* 0x06 command, 0x49 payload (LED flash, page 1, no forever), 0x0005 duration */
|
||||
const uint8_t blink_payload[6] = {0x06, 0x49, 0x00, 0x00, 0x00, 0x05};
|
||||
app->frame_lengths[1] = tagtinker_make_addressed_frame(
|
||||
app->frame_sequence[1], target->plid, blink_payload, 6);
|
||||
app->frame_repeats[1] = 100;
|
||||
app->frame_repeats[1] = 80;
|
||||
|
||||
memcpy(app->frame_buf, app->frame_sequence[0], app->frame_lengths[0]);
|
||||
app->frame_len = app->frame_lengths[0];
|
||||
@@ -161,6 +124,9 @@ bool tagtinker_scene_target_actions_on_event(void* ctx, SceneManagerEvent event)
|
||||
return true;
|
||||
case TagTinkerTargetDeleteTag:
|
||||
{
|
||||
if(app->selected_target < 0 || app->selected_target >= app->target_count) {
|
||||
return true;
|
||||
}
|
||||
TagTinkerTarget* target = &app->targets[app->selected_target];
|
||||
char body[96];
|
||||
snprintf(body, sizeof(body), "Delete %s and its\nsaved images?", target->name);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#include "../tagtinker_app.h"
|
||||
|
||||
void tagtinker_scene_text_box_on_enter(void* ctx) {
|
||||
TagTinkerApp* app = ctx;
|
||||
view_dispatcher_switch_to_view(app->view_dispatcher, TagTinkerViewTextBox);
|
||||
}
|
||||
|
||||
bool tagtinker_scene_text_box_on_event(void* ctx, SceneManagerEvent event) {
|
||||
TagTinkerApp* app = ctx;
|
||||
bool handled = false;
|
||||
|
||||
if(event.type == SceneManagerEventTypeBack) {
|
||||
handled = scene_manager_previous_scene(app->scene_manager);
|
||||
}
|
||||
|
||||
return handled;
|
||||
}
|
||||
|
||||
void tagtinker_scene_text_box_on_exit(void* ctx) {
|
||||
TagTinkerApp* app = ctx;
|
||||
text_box_reset(app->text_box);
|
||||
}
|
||||
@@ -64,7 +64,7 @@ bool tagtinker_scene_text_input_on_event(void* ctx, SceneManagerEvent event) {
|
||||
TagTinkerTarget* target = &app->targets[app->selected_target];
|
||||
text_input_sanitize_name(app->text_input_buf);
|
||||
if(strlen(app->text_input_buf) == 0U) {
|
||||
tagtinker_target_set_default_name(target);
|
||||
tagtinker_target_set_default_name(app, target);
|
||||
} else {
|
||||
strncpy(target->name, app->text_input_buf, TAGTINKER_TARGET_NAME_LEN);
|
||||
target->name[TAGTINKER_TARGET_NAME_LEN] = '\0';
|
||||
|
||||
+256
-169
@@ -24,20 +24,21 @@ typedef struct {
|
||||
bool top_down;
|
||||
} TxBmpInfo;
|
||||
|
||||
static bool tx_is_pp16(const TagTinkerApp* app);
|
||||
|
||||
/* Full-job threshold: if the whole image fits in memory, render it in one
|
||||
* shot instead of streaming in chunks. 49152 covers 208×112×2 = 46592
|
||||
* pixels (the most common color DM images). */
|
||||
#define TX_FULL_JOB_PIXEL_LIMIT 49152U
|
||||
|
||||
static uint16_t tx_pick_chunk_height(uint16_t width, uint16_t height, bool second_plane);
|
||||
|
||||
static void tx_debug_log(const char* fmt, ...) {
|
||||
UNUSED(fmt);
|
||||
}
|
||||
|
||||
|
||||
static uint16_t tx_apply_signal_mode(const TagTinkerApp* app, uint16_t repeats) {
|
||||
if(repeats & 0x8000U) return repeats;
|
||||
|
||||
if(tx_is_pp16(app)) {
|
||||
return (uint16_t)(repeats | 0x8000U);
|
||||
}
|
||||
|
||||
return repeats;
|
||||
UNUSED(app);
|
||||
return repeats & 0x7FFFU;
|
||||
}
|
||||
|
||||
static TagTinkerTagColor tx_target_color(const TagTinkerApp* app) {
|
||||
@@ -48,10 +49,6 @@ static TagTinkerTagColor tx_target_color(const TagTinkerApp* app) {
|
||||
return app->targets[app->selected_target].profile.color;
|
||||
}
|
||||
|
||||
static bool tx_is_pp16(const TagTinkerApp* app) {
|
||||
return app->signal_mode == TagTinkerSignalPP16;
|
||||
}
|
||||
|
||||
static bool tx_send_frame(TagTinkerApp* app, const uint8_t* frame, size_t len, uint16_t repeats) {
|
||||
if(!app->tx_active) return false;
|
||||
return tagtinker_ir_transmit(frame, len, tx_apply_signal_mode(app, repeats), 10);
|
||||
@@ -60,8 +57,7 @@ static bool tx_send_frame(TagTinkerApp* app, const uint8_t* frame, size_t len, u
|
||||
static bool tx_send_ping(TagTinkerApp* app, const uint8_t plid[4]) {
|
||||
uint8_t frame[TAGTINKER_MAX_FRAME_SIZE];
|
||||
size_t len = tagtinker_make_ping_frame(frame, plid);
|
||||
uint16_t repeats = tx_is_pp16(app) ? 500U : 250U;
|
||||
return tx_send_frame(app, frame, len, repeats);
|
||||
return tx_send_frame(app, frame, len, 250);
|
||||
}
|
||||
|
||||
static bool tx_send_refresh(TagTinkerApp* app, const uint8_t plid[4]) {
|
||||
@@ -89,7 +85,7 @@ static bool tx_send_image_start(
|
||||
uint8_t frame[TAGTINKER_MAX_FRAME_SIZE];
|
||||
size_t len = tagtinker_make_image_param_frame(
|
||||
frame, plid, byte_count, comp_type, page, width, height, pos_x, pos_y);
|
||||
return tx_send_frame(app, frame, len, 10);
|
||||
return tx_send_frame(app, frame, len, 30);
|
||||
}
|
||||
|
||||
static bool tx_send_payload_frames(
|
||||
@@ -112,12 +108,17 @@ static bool tx_send_payload_frames(
|
||||
height,
|
||||
pos_x,
|
||||
pos_y);
|
||||
if(ok) furi_delay_ms(tx_is_pp16(app) ? 80 : 120);
|
||||
if(ok) furi_delay_ms(120);
|
||||
|
||||
size_t frame_count = payload->byte_count / 20U;
|
||||
size_t frame_count = payload->byte_count / TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME;
|
||||
for(size_t i = 0; ok && i < frame_count; i++) {
|
||||
size_t len = tagtinker_make_image_data_frame(frame, plid, (uint16_t)i, &payload->data[i * 20U]);
|
||||
size_t len = tagtinker_make_image_data_frame(
|
||||
frame,
|
||||
plid,
|
||||
(uint16_t)i,
|
||||
&payload->data[i * TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME]);
|
||||
ok = tx_send_frame(app, frame, len, app->data_frame_repeats);
|
||||
/* Short delay to avoid tag overflow */
|
||||
if(ok && ((i + 1U) % 16U) == 0U && (i + 1U) < frame_count) {
|
||||
furi_delay_ms(1);
|
||||
}
|
||||
@@ -162,18 +163,21 @@ static uint32_t tx_chunk_settle_delay_ms(uint16_t width, uint16_t height, bool c
|
||||
}
|
||||
|
||||
static uint16_t tx_pick_chunk_height(uint16_t width, uint16_t height, bool second_plane) {
|
||||
uint16_t chunk_h = tagtinker_pick_chunk_height(width, second_plane);
|
||||
size_t budget = second_plane ? 24576U : 24576U;
|
||||
|
||||
if(second_plane && (height % 2U) == 0U) {
|
||||
uint16_t half_h = height / 2U;
|
||||
if(((size_t)width * half_h * 2U) <= budget) {
|
||||
return half_h;
|
||||
}
|
||||
}
|
||||
UNUSED(second_plane);
|
||||
if(width == 0U || height == 0U) return 1U;
|
||||
|
||||
/*
|
||||
* Keep each plane buffer ≤ 8 KB so two planes + encode overhead fits
|
||||
* in the Flipper's heap even right after BLE teardown.
|
||||
* 8 KB (down from 12 KB) leaves extra headroom for the encoder's
|
||||
* internal allocations and any lingering BLE buffers.
|
||||
*/
|
||||
size_t per_plane_budget = 8192U; /* 8 KB */
|
||||
uint16_t chunk_h = (uint16_t)(per_plane_budget / width);
|
||||
if(chunk_h == 0U) chunk_h = 1U;
|
||||
if(chunk_h > height) chunk_h = height;
|
||||
|
||||
/* Round down to 8-row boundary for alignment, but keep at least 1 */
|
||||
if(chunk_h >= 16U) {
|
||||
chunk_h = (uint16_t)(chunk_h & ~7U);
|
||||
if(chunk_h == 0U) chunk_h = 8U;
|
||||
@@ -226,8 +230,8 @@ static bool tx_send_full_text_image(TagTinkerApp* app) {
|
||||
if(accent_text) {
|
||||
uint8_t bg_primary = app->invert_text ? 0 : 1;
|
||||
uint8_t fg_primary = (accent_color == TagTinkerTagColorYellow) ? 0 : 1;
|
||||
render_text_ex(primary, job->width, job->height, app->text_input_buf, bg_primary, fg_primary);
|
||||
render_text_ex(secondary, job->width, job->height, app->text_input_buf, 1, 0);
|
||||
render_text_ex(primary, job->width, job->height, app->text_input_buf, bg_primary, fg_primary, app->text_padding_pct);
|
||||
render_text_ex(secondary, job->width, job->height, app->text_input_buf, 1, 0, app->text_padding_pct);
|
||||
} else {
|
||||
render_text_ex(
|
||||
primary,
|
||||
@@ -235,7 +239,8 @@ static bool tx_send_full_text_image(TagTinkerApp* app) {
|
||||
job->height,
|
||||
app->text_input_buf,
|
||||
app->invert_text ? 0 : 1,
|
||||
app->invert_text ? 1 : 0);
|
||||
app->invert_text ? 1 : 0,
|
||||
app->text_padding_pct);
|
||||
if(secondary) memset(secondary, 1, pixel_count);
|
||||
}
|
||||
|
||||
@@ -298,7 +303,8 @@ static bool tx_stream_text_image(TagTinkerApp* app) {
|
||||
actual_h,
|
||||
app->text_input_buf,
|
||||
bg_primary,
|
||||
fg_primary);
|
||||
fg_primary,
|
||||
app->text_padding_pct);
|
||||
render_text_region_ex(
|
||||
secondary,
|
||||
job->width,
|
||||
@@ -307,7 +313,8 @@ static bool tx_stream_text_image(TagTinkerApp* app) {
|
||||
actual_h,
|
||||
app->text_input_buf,
|
||||
1,
|
||||
0);
|
||||
0,
|
||||
app->text_padding_pct);
|
||||
} else {
|
||||
render_text_region_ex(
|
||||
primary,
|
||||
@@ -317,13 +324,14 @@ static bool tx_stream_text_image(TagTinkerApp* app) {
|
||||
actual_h,
|
||||
app->text_input_buf,
|
||||
app->invert_text ? 0 : 1,
|
||||
app->invert_text ? 1 : 0);
|
||||
app->invert_text ? 1 : 0,
|
||||
app->text_padding_pct);
|
||||
|
||||
if(secondary) memset(secondary, 1, (size_t)job->width * actual_h);
|
||||
}
|
||||
|
||||
ok = tx_send_ping(app, job->plid);
|
||||
if(ok) furi_delay_ms(120);
|
||||
if(ok) furi_delay_ms(30);
|
||||
ok = tx_send_image_chunk(
|
||||
app,
|
||||
job->plid,
|
||||
@@ -334,7 +342,7 @@ static bool tx_stream_text_image(TagTinkerApp* app) {
|
||||
job->page,
|
||||
job->pos_x,
|
||||
(uint16_t)(job->pos_y + y));
|
||||
if(ok) furi_delay_ms(100);
|
||||
if(ok) furi_delay_ms(20);
|
||||
if(ok) ok = tx_send_refresh(app, job->plid);
|
||||
|
||||
if(ok && (uint16_t)(y + actual_h) < job->height) {
|
||||
@@ -355,7 +363,7 @@ static bool tx_bmp_open(const char* path, File* file, TxBmpInfo* info) {
|
||||
if(header[0] != 'B' || header[1] != 'M') return false;
|
||||
|
||||
uint16_t bpp = header[28] | (header[29] << 8);
|
||||
if(!(bpp == 1 || bpp == 24 || bpp == 32)) return false;
|
||||
if(!(bpp == 1 || bpp == 2 || bpp == 24 || bpp == 32)) return false;
|
||||
info->bpp = bpp;
|
||||
|
||||
int32_t bmp_h = header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24);
|
||||
@@ -368,7 +376,7 @@ static bool tx_bmp_open(const char* path, File* file, TxBmpInfo* info) {
|
||||
|
||||
info->height = (uint16_t)bmp_h;
|
||||
info->data_offset = header[10] | (header[11] << 8) | (header[12] << 16) | (header[13] << 24);
|
||||
if(info->bpp == 1) {
|
||||
if(info->bpp == 1 || info->bpp == 2) {
|
||||
info->row_stride = ((info->width + 31U) / 32U) * 4U;
|
||||
} else if(info->bpp == 24) {
|
||||
info->row_stride = ((info->width * 3U) + 3U) & ~3U;
|
||||
@@ -378,168 +386,237 @@ static bool tx_bmp_open(const char* path, File* file, TxBmpInfo* info) {
|
||||
return true;
|
||||
}
|
||||
|
||||
static void tx_map_rgb_pixel(
|
||||
uint8_t r,
|
||||
uint8_t g,
|
||||
uint8_t b,
|
||||
TagTinkerTagColor accent_color,
|
||||
uint8_t* primary,
|
||||
uint8_t* secondary) {
|
||||
bool is_red = (r > 127U) && (g < 128U) && (b < 128U);
|
||||
bool is_yellow = (r > 127U) && (g > 127U) && (b < 128U);
|
||||
bool is_accent = is_red || is_yellow;
|
||||
if(is_accent) {
|
||||
*primary = (accent_color == TagTinkerTagColorYellow) ? 0U : 1U;
|
||||
*secondary = 0U;
|
||||
return;
|
||||
}
|
||||
/*
|
||||
* Zero-allocation streaming BMP transmitter with RLE compression.
|
||||
*
|
||||
* Two-pass approach:
|
||||
* Pass 1: Read all pixels from BMP, count RLE compressed bit length.
|
||||
* Pass 2: Re-read pixels, RLE-encode on-the-fly into IR data frames.
|
||||
*
|
||||
* Total stack usage: ~200 bytes. Zero heap allocation.
|
||||
*
|
||||
* Flow: PING -> PARAM (full image dims, RLE) -> DATA frames (streamed) -> REFRESH
|
||||
*/
|
||||
|
||||
uint16_t luma = (uint16_t)(21U * r + 72U * g + 7U * b);
|
||||
*primary = (luma < 12800U) ? 0U : 1U;
|
||||
*secondary = 1U;
|
||||
/* Elias gamma bit length for a run count */
|
||||
static inline size_t rle_run_bits(uint32_t count) {
|
||||
uint8_t n = 0;
|
||||
uint32_t v = count;
|
||||
while(v) { n++; v >>= 1; }
|
||||
return (size_t)(n * 2U) - 1U;
|
||||
}
|
||||
|
||||
static bool tx_bmp_read_chunk(
|
||||
File* file,
|
||||
const TxBmpInfo* info,
|
||||
TagTinkerTagColor accent_color,
|
||||
uint16_t chunk_y,
|
||||
uint16_t chunk_h,
|
||||
uint8_t* primary,
|
||||
uint8_t* secondary) {
|
||||
uint8_t* row_buf = malloc(info->row_stride);
|
||||
if(!row_buf) return false;
|
||||
static inline uint8_t bmp_read_pixel(const uint8_t* row_buf, uint16_t x) {
|
||||
uint8_t byte = row_buf[x / 8U];
|
||||
uint8_t bit = (byte >> (7U - (x % 8U))) & 1U;
|
||||
return bit ? 0U : 1U;
|
||||
}
|
||||
|
||||
memset(primary, 1, (size_t)info->width * chunk_h);
|
||||
if(secondary) memset(secondary, 1, (size_t)info->width * chunk_h);
|
||||
|
||||
bool ok = true;
|
||||
for(uint16_t local_y = 0; local_y < chunk_h; local_y++) {
|
||||
uint16_t global_y = chunk_y + local_y;
|
||||
uint32_t source_row = info->top_down ? global_y : (info->height - 1U - global_y);
|
||||
uint32_t offset = info->data_offset + source_row * info->row_stride;
|
||||
|
||||
if(!storage_file_seek(file, offset, true) ||
|
||||
storage_file_read(file, row_buf, info->row_stride) != info->row_stride) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
|
||||
for(uint16_t x = 0; x < info->width; x++) {
|
||||
size_t idx = (size_t)local_y * info->width + x;
|
||||
|
||||
if(info->bpp == 1) {
|
||||
uint8_t byte = row_buf[x / 8U];
|
||||
uint8_t bit = (byte >> (7U - (x % 8U))) & 1U;
|
||||
primary[idx] = bit ? 1U : 0U;
|
||||
if(secondary) secondary[idx] = 1U;
|
||||
} else {
|
||||
uint32_t pixel_off = x * (info->bpp / 8U);
|
||||
uint8_t b = row_buf[pixel_off + 0U];
|
||||
uint8_t g = row_buf[pixel_off + 1U];
|
||||
uint8_t r = row_buf[pixel_off + 2U];
|
||||
if(secondary) {
|
||||
tx_map_rgb_pixel(r, g, b, accent_color, &primary[idx], &secondary[idx]);
|
||||
} else {
|
||||
uint16_t luma = (uint16_t)(21U * r + 72U * g + 7U * b);
|
||||
primary[idx] = (luma < 12800U) ? 0U : 1U;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free(row_buf);
|
||||
return ok;
|
||||
static inline bool bmp_read_row(
|
||||
File* file, const TxBmpInfo* info,
|
||||
uint16_t y, uint16_t tx_height, uint16_t plane_offset,
|
||||
uint8_t* row_buf) {
|
||||
uint16_t src_row = info->top_down ? y : (uint16_t)(tx_height - 1U - y);
|
||||
uint32_t off = info->data_offset +
|
||||
((uint32_t)src_row + (uint32_t)plane_offset) * info->row_stride;
|
||||
storage_file_seek(file, off, true);
|
||||
return storage_file_read(file, row_buf, info->row_stride) == info->row_stride;
|
||||
}
|
||||
|
||||
static bool tx_stream_bmp_image(TagTinkerApp* app) {
|
||||
const TagTinkerImageTxJob* job = &app->image_tx_job;
|
||||
tx_debug_log("BMP TX: path=%s w=%u h=%u page=%u",
|
||||
job->image_path, job->width, job->height, job->page);
|
||||
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
File* file = storage_file_alloc(storage);
|
||||
TxBmpInfo info = {0};
|
||||
bool ok = tx_bmp_open(job->image_path, file, &info);
|
||||
tx_debug_log("bmp_open=%d bmp_w=%u bmp_h=%u bpp=%u off=%lu",
|
||||
ok, info.width, info.height, info.bpp, (unsigned long)info.data_offset);
|
||||
|
||||
if(ok && (info.width != job->width || info.height != job->height)) {
|
||||
ok = false;
|
||||
if(!ok) {
|
||||
storage_file_close(file);
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t tx_width = info.width;
|
||||
uint16_t tx_height = info.height;
|
||||
|
||||
TagTinkerTagColor accent_color = tx_target_color(app);
|
||||
bool accent_capable =
|
||||
accent_color == TagTinkerTagColorRed || accent_color == TagTinkerTagColorYellow;
|
||||
bool use_second_plane = app->color_clear || accent_capable;
|
||||
bool has_secondary_in_bmp = (info.bpp == 2);
|
||||
UNUSED(accent_color);
|
||||
|
||||
if(ok && tx_should_send_full_job(job->width, job->height, use_second_plane)) {
|
||||
size_t pixel_count = (size_t)job->width * job->height;
|
||||
uint8_t* primary = malloc(pixel_count);
|
||||
uint8_t* secondary = use_second_plane ? malloc(pixel_count) : NULL;
|
||||
uint8_t row_buf[64];
|
||||
|
||||
if(!primary || (use_second_plane && !secondary)) {
|
||||
ok = false;
|
||||
} else {
|
||||
ok = tx_bmp_read_chunk(file, &info, accent_color, 0, job->height, primary, secondary);
|
||||
if(ok) {
|
||||
TagTinkerImagePayload payload;
|
||||
ok = tagtinker_encode_planes_payload(
|
||||
primary, secondary, pixel_count, app->compression_mode, &payload);
|
||||
if(ok) {
|
||||
ok = tx_send_full_payload(
|
||||
app,
|
||||
job->plid,
|
||||
&payload,
|
||||
job->page,
|
||||
job->width,
|
||||
job->height,
|
||||
job->pos_x,
|
||||
job->pos_y);
|
||||
tagtinker_free_image_payload(&payload);
|
||||
/* ---- PASS 1: Count RLE compressed bit length ---- */
|
||||
size_t rle_bits = 0;
|
||||
uint8_t run_pixel = 0;
|
||||
uint32_t run_count = 0;
|
||||
bool first = true;
|
||||
|
||||
for(uint16_t y = 0; ok && y < tx_height; y++) {
|
||||
if(!bmp_read_row(file, &info, y, tx_height, 0, row_buf)) { ok = false; break; }
|
||||
for(uint16_t x = 0; x < tx_width; x++) {
|
||||
uint8_t pix = bmp_read_pixel(row_buf, x);
|
||||
if(first) { rle_bits = 1; run_pixel = pix; run_count = 1; first = false; }
|
||||
else if(pix == run_pixel) { run_count++; }
|
||||
else { rle_bits += rle_run_bits(run_count); run_pixel = pix; run_count = 1; }
|
||||
}
|
||||
}
|
||||
if(ok && use_second_plane) {
|
||||
if(has_secondary_in_bmp) {
|
||||
for(uint16_t y = 0; ok && y < tx_height; y++) {
|
||||
if(!bmp_read_row(file, &info, y, tx_height, tx_height, row_buf)) { ok = false; break; }
|
||||
for(uint16_t x = 0; x < tx_width; x++) {
|
||||
uint8_t pix = bmp_read_pixel(row_buf, x);
|
||||
if(first) { rle_bits = 1; run_pixel = pix; run_count = 1; first = false; }
|
||||
else if(pix == run_pixel) { run_count++; }
|
||||
else { rle_bits += rle_run_bits(run_count); run_pixel = pix; run_count = 1; }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uint32_t count = (uint32_t)tx_width * tx_height;
|
||||
if(first) { rle_bits = 1; run_pixel = 1; run_count = count; first = false; }
|
||||
else if(1U == run_pixel) { run_count += count; }
|
||||
else { rle_bits += rle_run_bits(run_count); run_pixel = 1; run_count = count; }
|
||||
}
|
||||
}
|
||||
if(run_count > 0) rle_bits += rle_run_bits(run_count);
|
||||
|
||||
free(primary);
|
||||
free(secondary);
|
||||
if(!ok) {
|
||||
storage_file_close(file);
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
return ok;
|
||||
return false;
|
||||
}
|
||||
|
||||
uint16_t chunk_h = tx_pick_chunk_height(job->width, job->height, use_second_plane);
|
||||
uint8_t* primary = ok ? malloc((size_t)job->width * chunk_h) : NULL;
|
||||
uint8_t* secondary = (ok && use_second_plane) ? malloc((size_t)job->width * chunk_h) : NULL;
|
||||
if(ok && (!primary || (use_second_plane && !secondary))) ok = false;
|
||||
|
||||
for(uint16_t y = 0; ok && y < job->height; y = (uint16_t)(y + chunk_h)) {
|
||||
uint16_t actual_h = job->height - y;
|
||||
if(actual_h > chunk_h) actual_h = chunk_h;
|
||||
|
||||
ok = tx_send_ping(app, job->plid);
|
||||
if(ok) furi_delay_ms(120);
|
||||
ok = tx_bmp_read_chunk(file, &info, accent_color, y, actual_h, primary, secondary);
|
||||
if(ok)
|
||||
ok = tx_send_image_chunk(
|
||||
app,
|
||||
job->plid,
|
||||
primary,
|
||||
secondary,
|
||||
job->width,
|
||||
actual_h,
|
||||
job->page,
|
||||
job->pos_x,
|
||||
(uint16_t)(job->pos_y + y));
|
||||
if(ok) furi_delay_ms(100);
|
||||
if(ok) ok = tx_send_refresh(app, job->plid);
|
||||
tx_debug_log("STREAM RLE: bits=%u", (unsigned)rle_bits);
|
||||
|
||||
if(ok && (uint16_t)(y + actual_h) < job->height) {
|
||||
furi_delay_ms(tx_chunk_settle_delay_ms(job->width, actual_h, use_second_plane));
|
||||
uint32_t raw_bits = (uint32_t)tx_width * tx_height;
|
||||
if(use_second_plane) raw_bits *= 2U;
|
||||
|
||||
bool use_compressed = (app->compression_mode == TagTinkerCompressionRle) ||
|
||||
(app->compression_mode == TagTinkerCompressionAuto && rle_bits > 0U && rle_bits < raw_bits);
|
||||
|
||||
uint32_t target_bits = use_compressed ? (uint32_t)rle_bits : raw_bits;
|
||||
uint32_t padded_bytes = (target_bits + 7U) / 8U;
|
||||
padded_bytes += (TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME - (padded_bytes % TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME)) % TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME;
|
||||
|
||||
uint8_t* encoded = calloc(padded_bytes, 1);
|
||||
if(!encoded) {
|
||||
tx_debug_log("RLE/RAW encode malloc fail");
|
||||
storage_file_close(file);
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
return false;
|
||||
}
|
||||
|
||||
if(use_compressed) {
|
||||
/* ---- PASS 2: RLE encode into a small heap buffer ---- */
|
||||
size_t enc_bit_pos = 0;
|
||||
|
||||
#define ENC_BIT(b) do { \
|
||||
if(b) encoded[enc_bit_pos / 8U] |= (1U << (7U - (enc_bit_pos % 8U))); \
|
||||
enc_bit_pos++; \
|
||||
} while(0)
|
||||
|
||||
#define ENC_RUN(cnt) do { \
|
||||
uint8_t _bits[32]; int _n = 0; uint32_t _v = (cnt); \
|
||||
while(_v) { _bits[_n++] = _v & 1U; _v >>= 1; } \
|
||||
for(int _i = 0; _i < _n/2; _i++) { uint8_t _t = _bits[_i]; _bits[_i] = _bits[_n-1-_i]; _bits[_n-1-_i] = _t; } \
|
||||
for(int _i = 1; _i < _n; _i++) ENC_BIT(0U); \
|
||||
for(int _i = 0; _i < _n; _i++) ENC_BIT(_bits[_i]); \
|
||||
} while(0)
|
||||
|
||||
run_pixel = 0; run_count = 0; first = true;
|
||||
|
||||
for(uint16_t y = 0; ok && y < tx_height; y++) {
|
||||
if(!bmp_read_row(file, &info, y, tx_height, 0, row_buf)) { ok = false; break; }
|
||||
for(uint16_t x = 0; x < tx_width; x++) {
|
||||
uint8_t pix = bmp_read_pixel(row_buf, x);
|
||||
if(first) { ENC_BIT(pix); run_pixel = pix; run_count = 1; first = false; }
|
||||
else if(pix == run_pixel) { run_count++; }
|
||||
else { ENC_RUN(run_count); run_pixel = pix; run_count = 1; }
|
||||
}
|
||||
}
|
||||
if(ok && use_second_plane) {
|
||||
if(has_secondary_in_bmp) {
|
||||
for(uint16_t y = 0; ok && y < tx_height; y++) {
|
||||
if(!bmp_read_row(file, &info, y, tx_height, tx_height, row_buf)) { ok = false; break; }
|
||||
for(uint16_t x = 0; x < tx_width; x++) {
|
||||
uint8_t pix = bmp_read_pixel(row_buf, x);
|
||||
if(first) { ENC_BIT(pix); run_pixel = pix; run_count = 1; first = false; }
|
||||
else if(pix == run_pixel) { run_count++; }
|
||||
else { ENC_RUN(run_count); run_pixel = pix; run_count = 1; }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uint32_t count = (uint32_t)tx_width * tx_height;
|
||||
if(first) { ENC_BIT(1U); run_pixel = 1; run_count = count; first = false; }
|
||||
else if(1U == run_pixel) { run_count += count; }
|
||||
else { ENC_RUN(run_count); run_pixel = 1; run_count = count; }
|
||||
}
|
||||
}
|
||||
if(run_count > 0) { ENC_RUN(run_count); }
|
||||
|
||||
#undef ENC_BIT
|
||||
#undef ENC_RUN
|
||||
} else {
|
||||
/* ---- PASS 2: RAW encode into heap buffer ---- */
|
||||
size_t bit_idx = 0;
|
||||
for(uint16_t y = 0; ok && y < tx_height; y++) {
|
||||
if(!bmp_read_row(file, &info, y, tx_height, 0, row_buf)) { ok = false; break; }
|
||||
for(uint16_t x = 0; x < tx_width; x++) {
|
||||
uint8_t pix = bmp_read_pixel(row_buf, x);
|
||||
if(pix != 0) encoded[bit_idx / 8U] |= (1U << (7U - (bit_idx % 8U)));
|
||||
bit_idx++;
|
||||
}
|
||||
}
|
||||
if(ok && use_second_plane) {
|
||||
if(has_secondary_in_bmp) {
|
||||
for(uint16_t y = 0; ok && y < tx_height; y++) {
|
||||
if(!bmp_read_row(file, &info, y, tx_height, tx_height, row_buf)) { ok = false; break; }
|
||||
for(uint16_t x = 0; x < tx_width; x++) {
|
||||
uint8_t pix = bmp_read_pixel(row_buf, x);
|
||||
if(pix != 0) encoded[bit_idx / 8U] |= (1U << (7U - (bit_idx % 8U)));
|
||||
bit_idx++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uint32_t count = (uint32_t)tx_width * tx_height;
|
||||
for(uint32_t i = 0; i < count; i++) {
|
||||
encoded[bit_idx / 8U] |= (1U << (7U - (bit_idx % 8U)));
|
||||
bit_idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free(primary);
|
||||
free(secondary);
|
||||
storage_file_close(file);
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
|
||||
if(!ok) { free(encoded); return false; }
|
||||
|
||||
/* Transmit encoded buffer */
|
||||
TagTinkerImagePayload payload;
|
||||
payload.data = encoded;
|
||||
payload.byte_count = padded_bytes;
|
||||
payload.comp_type = use_compressed ? 2U : 0U;
|
||||
|
||||
ok = tx_send_full_payload(
|
||||
app, job->plid, &payload, job->page,
|
||||
tx_width, tx_height, job->pos_x, job->pos_y);
|
||||
|
||||
free(encoded);
|
||||
return ok;
|
||||
}
|
||||
|
||||
@@ -547,8 +624,16 @@ static int32_t tx_thread_callback(void* context) {
|
||||
TagTinkerApp* app = context;
|
||||
bool ok = true;
|
||||
|
||||
/* Boost priority for IR timing */
|
||||
furi_thread_set_current_priority(FuriThreadPriorityHighest);
|
||||
|
||||
tagtinker_ir_init();
|
||||
|
||||
/* Let OS settle (especially BLE teardown) before IR blasting */
|
||||
if(app->image_tx_job.mode == TagTinkerTxModeBmpImage || app->image_tx_job.mode == TagTinkerTxModeTextImage) {
|
||||
furi_delay_ms(1000);
|
||||
}
|
||||
|
||||
do {
|
||||
if(app->image_tx_job.mode == TagTinkerTxModeTextImage) {
|
||||
ok = tx_stream_text_image(app);
|
||||
@@ -560,11 +645,12 @@ static int32_t tx_thread_callback(void* context) {
|
||||
if(i > 0) furi_delay_ms(20);
|
||||
ok = tagtinker_ir_transmit(
|
||||
app->frame_sequence[i], app->frame_lengths[i],
|
||||
app->frame_repeats[i], 10);
|
||||
tx_apply_signal_mode(app, app->frame_repeats[i]), 10);
|
||||
if(!ok) break;
|
||||
}
|
||||
} else {
|
||||
ok = tagtinker_ir_transmit(app->frame_buf, app->frame_len, app->repeats, 10);
|
||||
ok = tagtinker_ir_transmit(
|
||||
app->frame_buf, app->frame_len, tx_apply_signal_mode(app, app->repeats), 10);
|
||||
}
|
||||
|
||||
if(app->tx_spam && app->tx_active) {
|
||||
@@ -671,10 +757,9 @@ static void transmit_draw_cb(Canvas* canvas, void* _model) {
|
||||
bool show_result = (cycle >= 20);
|
||||
|
||||
if(!show_result) {
|
||||
canvas_set_font(canvas, FontSecondary);
|
||||
canvas_draw_str(canvas, scr_x + 2, scr_y + 11, "LAB NOTE");
|
||||
canvas_set_font(canvas, FontPrimary);
|
||||
canvas_draw_str(canvas, scr_x + 5, scr_y + 26, "STUDY");
|
||||
canvas_draw_str_aligned(
|
||||
canvas, scr_x + scr_w / 2, scr_y + scr_h / 2, AlignCenter, AlignCenter, "UPDATING");
|
||||
} else {
|
||||
canvas_set_font(canvas, FontSecondary);
|
||||
canvas_draw_str_aligned(
|
||||
@@ -746,9 +831,11 @@ bool tagtinker_scene_transmit_on_event(void* context, SceneManagerEvent event) {
|
||||
return true;
|
||||
} else {
|
||||
if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneTargetActions)) {
|
||||
if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneBroadcast)) {
|
||||
if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneBroadcastMenu)) {
|
||||
scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneMainMenu);
|
||||
if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneAbout)) {
|
||||
if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneBroadcast)) {
|
||||
if(!scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneBroadcastMenu)) {
|
||||
scene_manager_search_and_switch_to_previous_scene(app->scene_manager, TagTinkerSceneMainMenu);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+429
-236
@@ -12,6 +12,8 @@
|
||||
|
||||
#include "tagtinker_app.h"
|
||||
|
||||
#define TAGTINKER_WEB_JOB_PATH APP_DATA_PATH("web_job.txt")
|
||||
|
||||
static bool navigation_cb(void* ctx) {
|
||||
TagTinkerApp* app = ctx;
|
||||
return scene_manager_handle_back_event(app->scene_manager);
|
||||
@@ -29,7 +31,7 @@ static bool custom_event_cb(void* ctx, uint32_t event) {
|
||||
|
||||
extern const SceneManagerHandlers tagtinker_scene_handlers;
|
||||
|
||||
#define TAGTINKER_STREAM_PIXEL_BUDGET 24576U
|
||||
#define TAGTINKER_STREAM_PIXEL_BUDGET 131072U
|
||||
|
||||
static void tagtinker_clamp_region_to_target(
|
||||
const TagTinkerApp* app,
|
||||
@@ -59,18 +61,9 @@ void tagtinker_target_refresh_profile(TagTinkerTarget* target) {
|
||||
tagtinker_barcode_to_profile(target->barcode, &target->profile);
|
||||
}
|
||||
|
||||
void tagtinker_target_set_default_name(TagTinkerTarget* target) {
|
||||
if(!target) return;
|
||||
|
||||
if(strlen(target->barcode) < 6U) {
|
||||
snprintf(target->name, sizeof(target->name), "Tag");
|
||||
return;
|
||||
}
|
||||
|
||||
char suffix[7];
|
||||
memcpy(suffix, target->barcode + TAGTINKER_BC_LEN - 6, 6);
|
||||
suffix[6] = '\0';
|
||||
snprintf(target->name, sizeof(target->name), "Tag ...%s", suffix);
|
||||
void tagtinker_target_set_default_name(TagTinkerApp* app, TagTinkerTarget* target) {
|
||||
if(!target || !app) return;
|
||||
snprintf(target->name, sizeof(target->name), "tag%d", app->target_count + 1);
|
||||
}
|
||||
|
||||
int8_t tagtinker_find_target_by_barcode(const TagTinkerApp* app, const char* barcode) {
|
||||
@@ -102,7 +95,7 @@ int8_t tagtinker_ensure_target(TagTinkerApp* app, const char* barcode) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
tagtinker_target_set_default_name(target);
|
||||
tagtinker_target_set_default_name(app, target);
|
||||
tagtinker_target_refresh_profile(target);
|
||||
app->target_count++;
|
||||
tagtinker_targets_save(app);
|
||||
@@ -113,62 +106,72 @@ bool tagtinker_find_latest_synced_image(
|
||||
const TagTinkerApp* app,
|
||||
const char* barcode,
|
||||
TagTinkerSyncedImage* image) {
|
||||
if(!app || !barcode || !*barcode || !image) return false;
|
||||
if(!app || !barcode || !image) return false;
|
||||
|
||||
bool found = false;
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
File* file = storage_file_alloc(storage);
|
||||
bool found = false;
|
||||
|
||||
if(storage_file_open(file, APP_DATA_PATH("synced_images.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
|
||||
uint64_t size = storage_file_size(file);
|
||||
if(size > 0U && size < 8192U) {
|
||||
char* buf = malloc((size_t)size + 1U);
|
||||
if(buf) {
|
||||
uint16_t read = storage_file_read(file, buf, (uint16_t)size);
|
||||
buf[read] = '\0';
|
||||
/*
|
||||
* Index file can grow well beyond 512 bytes with many synced images.
|
||||
* Previously the 512-byte buffer truncated file paths mid-name
|
||||
* (e.g. "D72B7A.bm" instead of "D72B7A.bmp"), causing bmp_open
|
||||
* to fail and the entire IR transmission to abort in ~1ms.
|
||||
*/
|
||||
uint64_t file_size = storage_file_size(file);
|
||||
if(file_size > 16384U) file_size = 16384U;
|
||||
size_t alloc_size = (size_t)file_size + 1U;
|
||||
char* buf = malloc(alloc_size);
|
||||
if(!buf) {
|
||||
storage_file_close(file);
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
return false;
|
||||
}
|
||||
uint16_t read = storage_file_read(file, buf, (uint16_t)file_size);
|
||||
buf[read] = '\0';
|
||||
storage_file_close(file);
|
||||
|
||||
char* line = buf;
|
||||
while(line && *line) {
|
||||
char* nl = strchr(line, '\n');
|
||||
if(nl) *nl = '\0';
|
||||
char* line = buf;
|
||||
while(line && *line) {
|
||||
char* nl = strchr(line, '\n');
|
||||
if(nl) *nl = '\0';
|
||||
|
||||
if(*line) {
|
||||
char* cursor = line;
|
||||
char* job_id = cursor;
|
||||
char* current_barcode = strchr(cursor, '|');
|
||||
if(current_barcode) *current_barcode++ = '\0';
|
||||
char* width = current_barcode ? strchr(current_barcode, '|') : NULL;
|
||||
if(width) *width++ = '\0';
|
||||
char* height = width ? strchr(width, '|') : NULL;
|
||||
if(height) *height++ = '\0';
|
||||
char* page = height ? strchr(height, '|') : NULL;
|
||||
if(page) *page++ = '\0';
|
||||
char* path = page ? strchr(page, '|') : NULL;
|
||||
if(path) *path++ = '\0';
|
||||
|
||||
if(job_id && current_barcode && width && height && page && path &&
|
||||
strcmp(current_barcode, barcode) == 0 && storage_common_exists(storage, path)) {
|
||||
strncpy(image->job_id, job_id, TAGTINKER_SYNC_JOB_ID_LEN);
|
||||
image->job_id[TAGTINKER_SYNC_JOB_ID_LEN] = '\0';
|
||||
strncpy(image->barcode, current_barcode, TAGTINKER_BC_LEN);
|
||||
image->barcode[TAGTINKER_BC_LEN] = '\0';
|
||||
image->width = (uint16_t)atoi(width);
|
||||
image->height = (uint16_t)atoi(height);
|
||||
image->page = (uint8_t)atoi(page);
|
||||
strncpy(image->image_path, path, TAGTINKER_IMAGE_PATH_LEN);
|
||||
image->image_path[TAGTINKER_IMAGE_PATH_LEN] = '\0';
|
||||
found = true;
|
||||
if(*line) {
|
||||
char* cursor = line;
|
||||
char* current_barcode = strchr(cursor, '|');
|
||||
if(current_barcode) {
|
||||
*current_barcode++ = '\0';
|
||||
char* width = strchr(current_barcode, '|');
|
||||
if(width) {
|
||||
*width++ = '\0';
|
||||
char* height = strchr(width, '|');
|
||||
if(height) {
|
||||
*height++ = '\0';
|
||||
char* page = strchr(height, '|');
|
||||
if(page) {
|
||||
*page++ = '\0';
|
||||
char* path = strchr(page, '|');
|
||||
if(path) {
|
||||
*path++ = '\0';
|
||||
if(strcmp(current_barcode, barcode) == 0) {
|
||||
strncpy(image->barcode, current_barcode, TAGTINKER_BC_LEN);
|
||||
image->width = (uint16_t)atoi(width);
|
||||
image->height = (uint16_t)atoi(height);
|
||||
image->page = (uint8_t)atoi(page);
|
||||
strncpy(image->image_path, path, TAGTINKER_IMAGE_PATH_LEN);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
line = nl ? (nl + 1) : NULL;
|
||||
}
|
||||
|
||||
free(buf);
|
||||
}
|
||||
line = nl ? (nl + 1) : NULL;
|
||||
}
|
||||
|
||||
storage_file_close(file);
|
||||
free(buf);
|
||||
}
|
||||
|
||||
storage_file_free(file);
|
||||
@@ -178,7 +181,6 @@ bool tagtinker_find_latest_synced_image(
|
||||
|
||||
size_t tagtinker_delete_synced_images_for_barcode(TagTinkerApp* app, const char* barcode) {
|
||||
UNUSED(app);
|
||||
|
||||
if(!barcode || !*barcode) return 0U;
|
||||
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
@@ -187,13 +189,12 @@ size_t tagtinker_delete_synced_images_for_barcode(TagTinkerApp* app, const char*
|
||||
|
||||
if(storage_file_open(file, APP_DATA_PATH("synced_images.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
|
||||
uint64_t size = storage_file_size(file);
|
||||
if(size > 0U && size < 8192U) {
|
||||
if(size > 0U && size < 16384U) {
|
||||
char* input = malloc((size_t)size + 1U);
|
||||
char* output = malloc((size_t)size + 1U);
|
||||
if(input && output) {
|
||||
uint16_t read = storage_file_read(file, input, (uint16_t)size);
|
||||
input[read] = '\0';
|
||||
output[0] = '\0';
|
||||
size_t output_len = 0U;
|
||||
|
||||
char* line = input;
|
||||
@@ -202,55 +203,57 @@ size_t tagtinker_delete_synced_images_for_barcode(TagTinkerApp* app, const char*
|
||||
if(nl) *nl = '\0';
|
||||
|
||||
if(*line) {
|
||||
char line_copy[384];
|
||||
snprintf(line_copy, sizeof(line_copy), "%s", line);
|
||||
char line_copy[256];
|
||||
strncpy(line_copy, line, sizeof(line_copy) - 1U);
|
||||
line_copy[sizeof(line_copy) - 1U] = '\0';
|
||||
|
||||
char* cursor = line;
|
||||
char* job_id = cursor;
|
||||
char* current_barcode = strchr(cursor, '|');
|
||||
if(current_barcode) *current_barcode++ = '\0';
|
||||
char* width = current_barcode ? strchr(current_barcode, '|') : NULL;
|
||||
if(width) *width++ = '\0';
|
||||
char* height = width ? strchr(width, '|') : NULL;
|
||||
if(height) *height++ = '\0';
|
||||
char* page = height ? strchr(height, '|') : NULL;
|
||||
if(page) *page++ = '\0';
|
||||
char* path = page ? strchr(page, '|') : NULL;
|
||||
if(path) *path++ = '\0';
|
||||
|
||||
bool matches =
|
||||
job_id && current_barcode && width && height && page && path &&
|
||||
strcmp(current_barcode, barcode) == 0;
|
||||
if(matches) {
|
||||
storage_common_remove(storage, path);
|
||||
removed_count++;
|
||||
} else {
|
||||
size_t line_len = strlen(line_copy);
|
||||
if((output_len + line_len + 1U) < ((size_t)size + 1U)) {
|
||||
memcpy(output + output_len, line_copy, line_len);
|
||||
output_len += line_len;
|
||||
output[output_len++] = '\n';
|
||||
output[output_len] = '\0';
|
||||
if(current_barcode) {
|
||||
*current_barcode++ = '\0';
|
||||
char* width = strchr(current_barcode, '|');
|
||||
if(width) {
|
||||
*width++ = '\0';
|
||||
char* height = strchr(width, '|');
|
||||
if(height) {
|
||||
*height++ = '\0';
|
||||
char* page = strchr(height, '|');
|
||||
if(page) {
|
||||
*page++ = '\0';
|
||||
char* path = strchr(page, '|');
|
||||
if(path) {
|
||||
*path++ = '\0';
|
||||
if(strcmp(current_barcode, barcode) == 0) {
|
||||
storage_common_remove(storage, path);
|
||||
removed_count++;
|
||||
} else {
|
||||
size_t line_len = strlen(line_copy);
|
||||
if(output_len + line_len + 2U <= (size_t)size + 1U) {
|
||||
strcpy(output + output_len, line_copy);
|
||||
output_len += line_len;
|
||||
output[output_len++] = '\n';
|
||||
output[output_len] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
line = nl ? (nl + 1) : NULL;
|
||||
}
|
||||
|
||||
storage_file_close(file);
|
||||
if(output_len == 0U) {
|
||||
storage_common_remove(storage, APP_DATA_PATH("synced_images.txt"));
|
||||
} else if(
|
||||
storage_file_open(
|
||||
file, APP_DATA_PATH("synced_images.txt"), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
|
||||
storage_file_write(file, output, (uint16_t)output_len);
|
||||
storage_file_close(file);
|
||||
if(removed_count > 0U) {
|
||||
if(output_len == 0U) {
|
||||
storage_common_remove(storage, APP_DATA_PATH("synced_images.txt"));
|
||||
} else if(storage_file_open(file, APP_DATA_PATH("synced_images.txt"), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
|
||||
storage_file_write(file, output, (uint16_t)output_len);
|
||||
storage_file_close(file);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
storage_file_close(file);
|
||||
}
|
||||
|
||||
free(output);
|
||||
free(input);
|
||||
} else {
|
||||
@@ -286,13 +289,11 @@ bool tagtinker_delete_target(TagTinkerApp* app, uint8_t index) {
|
||||
|
||||
bool tagtinker_target_supports_graphics(const TagTinkerTarget* target) {
|
||||
if(!target) return false;
|
||||
|
||||
return target->profile.kind != TagTinkerTagKindSegment;
|
||||
}
|
||||
|
||||
bool tagtinker_target_supports_accent(const TagTinkerTarget* target) {
|
||||
if(!target) return false;
|
||||
|
||||
return target->profile.color == TagTinkerTagColorRed ||
|
||||
target->profile.color == TagTinkerTagColorYellow;
|
||||
}
|
||||
@@ -300,7 +301,7 @@ bool tagtinker_target_supports_accent(const TagTinkerTarget* target) {
|
||||
const char* tagtinker_profile_kind_label(TagTinkerTagKind kind) {
|
||||
switch(kind) {
|
||||
case TagTinkerTagKindDotMatrix:
|
||||
return "Dot Matrix";
|
||||
return "Graphic";
|
||||
case TagTinkerTagKindSegment:
|
||||
return "Segment";
|
||||
default:
|
||||
@@ -310,12 +311,14 @@ const char* tagtinker_profile_kind_label(TagTinkerTagKind kind) {
|
||||
|
||||
const char* tagtinker_profile_color_label(TagTinkerTagColor color) {
|
||||
switch(color) {
|
||||
case TagTinkerTagColorMono:
|
||||
return "Mono";
|
||||
case TagTinkerTagColorRed:
|
||||
return "Red";
|
||||
case TagTinkerTagColorYellow:
|
||||
return "Yellow";
|
||||
default:
|
||||
return "Mono";
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +354,8 @@ void tagtinker_prepare_text_tx(TagTinkerApp* app, const uint8_t plid[4]) {
|
||||
memset(&app->image_tx_job, 0, sizeof(app->image_tx_job));
|
||||
app->image_tx_job.mode = TagTinkerTxModeTextImage;
|
||||
memcpy(app->image_tx_job.plid, plid, sizeof(app->image_tx_job.plid));
|
||||
app->image_tx_job.page = app->img_page;
|
||||
app->image_tx_job.page = (app->img_page > 0U) ? (uint8_t)(app->img_page - 1U) : 0U;
|
||||
if(app->image_tx_job.page > 7U) app->image_tx_job.page = 7U;
|
||||
app->image_tx_job.width = app->esl_width;
|
||||
app->image_tx_job.height = app->esl_height;
|
||||
app->image_tx_job.pos_x = app->draw_x;
|
||||
@@ -397,70 +401,49 @@ void tagtinker_prepare_bmp_tx(
|
||||
void tagtinker_select_target(TagTinkerApp* app, uint8_t index) {
|
||||
if(!app || index >= app->target_count) return;
|
||||
|
||||
TagTinkerTarget* target = &app->targets[index];
|
||||
app->selected_target = (int8_t)index;
|
||||
memcpy(app->barcode, target->barcode, TAGTINKER_BC_LEN + 1);
|
||||
memcpy(app->plid, target->plid, sizeof(target->plid));
|
||||
strncpy(app->barcode, app->targets[index].barcode, TAGTINKER_BC_LEN);
|
||||
app->barcode[TAGTINKER_BC_LEN] = '\0';
|
||||
memcpy(app->plid, app->targets[index].plid, sizeof(app->plid));
|
||||
app->barcode_valid = true;
|
||||
|
||||
if(target->profile.known && target->profile.kind == TagTinkerTagKindDotMatrix &&
|
||||
target->profile.width && target->profile.height) {
|
||||
app->esl_width = target->profile.width;
|
||||
app->esl_height = target->profile.height;
|
||||
app->esl_width = app->targets[index].profile.width;
|
||||
app->esl_height = app->targets[index].profile.height;
|
||||
|
||||
if(app->esl_width == 0 || app->esl_height == 0) {
|
||||
app->esl_width = 200;
|
||||
app->esl_height = 80;
|
||||
}
|
||||
}
|
||||
|
||||
void tagtinker_settings_load(TagTinkerApp* app) {
|
||||
app->show_startup_warning = true;
|
||||
app->signal_mode = TagTinkerSignalPP4;
|
||||
app->compression_mode = TagTinkerCompressionAuto;
|
||||
app->data_frame_repeats = 3;
|
||||
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
File* file = storage_file_alloc(storage);
|
||||
|
||||
app->show_startup_warning = true;
|
||||
app->data_frame_repeats = 3;
|
||||
app->signal_mode = TagTinkerSignalPP4;
|
||||
|
||||
if(storage_file_open(file, APP_DATA_PATH("settings.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
|
||||
char buf[64];
|
||||
char buf[32];
|
||||
uint16_t read = storage_file_read(file, buf, sizeof(buf) - 1);
|
||||
buf[read] = '\0';
|
||||
|
||||
if(strchr(buf, '=')) {
|
||||
char* line = buf;
|
||||
while(line && *line) {
|
||||
char* nl = strchr(line, '\n');
|
||||
if(nl) *nl = '\0';
|
||||
|
||||
if(strncmp(line, "warning=", 8) == 0) {
|
||||
app->show_startup_warning = (line[8] != '0');
|
||||
} else if(strncmp(line, "signal=", 7) == 0) {
|
||||
int value = atoi(line + 7);
|
||||
app->signal_mode = (value == 2) ? TagTinkerSignalPP16 : TagTinkerSignalPP4;
|
||||
} else if(strncmp(line, "compression=", 12) == 0) {
|
||||
int value = atoi(line + 12);
|
||||
if(value >= TagTinkerCompressionAuto && value <= TagTinkerCompressionRle) {
|
||||
app->compression_mode = (TagTinkerCompressionMode)value;
|
||||
}
|
||||
} else if(strncmp(line, "frame_repeat=", 13) == 0) {
|
||||
int value = atoi(line + 13);
|
||||
if(value >= 1 && value <= 5) {
|
||||
app->data_frame_repeats = (uint8_t)value;
|
||||
}
|
||||
} else if(strncmp(line, "draw_x=", 7) == 0) {
|
||||
int value = atoi(line + 7);
|
||||
if(value >= 0) app->draw_x = (uint16_t)value;
|
||||
} else if(strncmp(line, "draw_y=", 7) == 0) {
|
||||
int value = atoi(line + 7);
|
||||
if(value >= 0) app->draw_y = (uint16_t)value;
|
||||
}
|
||||
|
||||
line = nl ? nl + 1 : NULL;
|
||||
}
|
||||
} else if(read >= 1) {
|
||||
app->show_startup_warning = (buf[0] != '0');
|
||||
}
|
||||
storage_file_close(file);
|
||||
|
||||
int warn, rep, sig;
|
||||
if(sscanf(buf, "%d|%d|%d", &warn, &rep, &sig) == 3) {
|
||||
app->show_startup_warning = (warn != 0);
|
||||
app->data_frame_repeats = (uint16_t)rep;
|
||||
} else if(sscanf(buf, "%d|%d", &warn, &rep) == 2) {
|
||||
app->show_startup_warning = (warn != 0);
|
||||
app->data_frame_repeats = (uint16_t)rep;
|
||||
}
|
||||
}
|
||||
|
||||
if(app->data_frame_repeats < 1U) app->data_frame_repeats = 1U;
|
||||
if(app->data_frame_repeats > 10U) app->data_frame_repeats = 10U;
|
||||
app->signal_mode = TagTinkerSignalPP4;
|
||||
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
}
|
||||
@@ -473,18 +456,17 @@ bool tagtinker_settings_save(const TagTinkerApp* app) {
|
||||
bool ok = false;
|
||||
|
||||
if(storage_file_open(file, APP_DATA_PATH("settings.txt"), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
|
||||
char buf[128];
|
||||
char buf[32];
|
||||
int len = snprintf(
|
||||
buf,
|
||||
sizeof(buf),
|
||||
"warning=%d\nsignal=%d\ncompression=%d\nframe_repeat=%u\ndraw_x=%u\ndraw_y=%u\n",
|
||||
"%d|%u|%d",
|
||||
app->show_startup_warning ? 1 : 0,
|
||||
(int)app->signal_mode,
|
||||
(int)app->compression_mode,
|
||||
app->data_frame_repeats,
|
||||
app->draw_x,
|
||||
app->draw_y);
|
||||
ok = (len > 0) && storage_file_write(file, buf, (uint16_t)len);
|
||||
(int)TagTinkerSignalPP4);
|
||||
if(len > 0 && storage_file_write(file, buf, (uint16_t)len)) {
|
||||
ok = true;
|
||||
}
|
||||
storage_file_close(file);
|
||||
}
|
||||
|
||||
@@ -500,7 +482,7 @@ void tagtinker_targets_load(TagTinkerApp* app) {
|
||||
File* file = storage_file_alloc(storage);
|
||||
|
||||
if(storage_file_open(file, APP_DATA_PATH("targets.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
|
||||
char buf[768];
|
||||
char buf[1024];
|
||||
uint16_t read = storage_file_read(file, buf, sizeof(buf) - 1);
|
||||
buf[read] = '\0';
|
||||
storage_file_close(file);
|
||||
@@ -524,7 +506,7 @@ void tagtinker_targets_load(TagTinkerApp* app) {
|
||||
strncpy(target->name, sep + 1, TAGTINKER_TARGET_NAME_LEN);
|
||||
target->name[TAGTINKER_TARGET_NAME_LEN] = '\0';
|
||||
} else {
|
||||
tagtinker_target_set_default_name(target);
|
||||
tagtinker_target_set_default_name(app, target);
|
||||
}
|
||||
|
||||
tagtinker_target_refresh_profile(target);
|
||||
@@ -572,6 +554,256 @@ bool tagtinker_targets_save(const TagTinkerApp* app) {
|
||||
return ok;
|
||||
}
|
||||
|
||||
void tagtinker_recents_load(TagTinkerApp* app) {
|
||||
app->recent_count = 0;
|
||||
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
File* file = storage_file_alloc(storage);
|
||||
|
||||
if(storage_file_open(file, APP_DATA_PATH("recents.txt"), FSAM_READ, FSOM_OPEN_EXISTING)) {
|
||||
char buf[1024];
|
||||
uint16_t read = storage_file_read(file, buf, sizeof(buf) - 1);
|
||||
buf[read] = '\0';
|
||||
storage_file_close(file);
|
||||
|
||||
char* line = buf;
|
||||
while(line && *line && app->recent_count < TAGTINKER_MAX_PRESETS) {
|
||||
char* nl = strchr(line, '\n');
|
||||
if(nl) *nl = '\0';
|
||||
|
||||
unsigned w, h, pg, inv, clr, pad, sig;
|
||||
/* Parse current format: w|h|pg|inv|clr|pad|sig|text.
|
||||
Older saved entries omitted sig and are kept on the current mode. */
|
||||
int parsed =
|
||||
sscanf(line, "%u|%u|%u|%u|%u|%u|%u|", &w, &h, &pg, &inv, &clr, &pad, &sig);
|
||||
if(parsed >= 6) {
|
||||
char* p = line;
|
||||
int pipes = 0;
|
||||
int wanted_pipes = (parsed >= 7) ? 7 : 6;
|
||||
while(*p && pipes < wanted_pipes) {
|
||||
if(*p == '|') pipes++;
|
||||
p++;
|
||||
}
|
||||
|
||||
if(pipes == wanted_pipes) {
|
||||
uint8_t idx = app->recent_count++;
|
||||
app->recents[idx].width = (uint16_t)w;
|
||||
app->recents[idx].height = (uint16_t)h;
|
||||
app->recents[idx].page = (uint8_t)pg;
|
||||
app->recents[idx].invert = (inv != 0);
|
||||
app->recents[idx].color_clear = (clr != 0);
|
||||
app->recents[idx].padding = (uint8_t)pad;
|
||||
app->recents[idx].signal_mode = (uint8_t)TagTinkerSignalPP4;
|
||||
strncpy(app->recents[idx].text, p, TAGTINKER_PRESET_TEXT_LEN - 1);
|
||||
app->recents[idx].text[TAGTINKER_PRESET_TEXT_LEN - 1] = '\0';
|
||||
}
|
||||
}
|
||||
line = nl ? nl + 1 : NULL;
|
||||
}
|
||||
}
|
||||
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
}
|
||||
|
||||
bool tagtinker_recents_save(const TagTinkerApp* app) {
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
storage_common_mkdir(storage, APP_DATA_PATH(""));
|
||||
|
||||
File* file = storage_file_alloc(storage);
|
||||
bool ok = false;
|
||||
|
||||
if(storage_file_open(file, APP_DATA_PATH("recents.txt"), FSAM_WRITE, FSOM_CREATE_ALWAYS)) {
|
||||
ok = true;
|
||||
for(uint8_t i = 0; i < app->recent_count; i++) {
|
||||
char line[128];
|
||||
int len = snprintf(
|
||||
line,
|
||||
sizeof(line),
|
||||
"%u|%u|%u|%d|%d|%u|%u|%s\n",
|
||||
app->recents[i].width,
|
||||
app->recents[i].height,
|
||||
app->recents[i].page,
|
||||
app->recents[i].invert ? 1 : 0,
|
||||
app->recents[i].color_clear ? 1 : 0,
|
||||
app->recents[i].padding,
|
||||
(uint8_t)TagTinkerSignalPP4,
|
||||
app->recents[i].text);
|
||||
|
||||
if(len <= 0 || !storage_file_write(file, line, (uint16_t)len)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
storage_file_close(file);
|
||||
}
|
||||
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
return ok;
|
||||
}
|
||||
|
||||
void tagtinker_recents_add(TagTinkerApp* app, const char* text) {
|
||||
if(!app || !text || !*text) return;
|
||||
|
||||
/* Check if already in recents (move to top if so) */
|
||||
int8_t existing_idx = -1;
|
||||
for(uint8_t i = 0; i < app->recent_count; i++) {
|
||||
if(strcmp(app->recents[i].text, text) == 0 && app->recents[i].width == app->esl_width &&
|
||||
app->recents[i].height == app->esl_height) {
|
||||
existing_idx = (int8_t)i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(existing_idx >= 0) {
|
||||
/* Move to front */
|
||||
if(existing_idx > 0) {
|
||||
uint16_t width = app->recents[existing_idx].width;
|
||||
uint16_t height = app->recents[existing_idx].height;
|
||||
uint8_t page = app->recents[existing_idx].page;
|
||||
bool invert = app->recents[existing_idx].invert;
|
||||
bool color_clear = app->recents[existing_idx].color_clear;
|
||||
uint8_t padding = app->recents[existing_idx].padding;
|
||||
char text_copy[TAGTINKER_PRESET_TEXT_LEN];
|
||||
strncpy(text_copy, app->recents[existing_idx].text, TAGTINKER_PRESET_TEXT_LEN);
|
||||
|
||||
memmove(&app->recents[1], &app->recents[0], sizeof(app->recents[0]) * (size_t)existing_idx);
|
||||
|
||||
app->recents[0].width = width;
|
||||
app->recents[0].height = height;
|
||||
app->recents[0].page = page;
|
||||
app->recents[0].invert = invert;
|
||||
app->recents[0].color_clear = color_clear;
|
||||
app->recents[0].padding = padding;
|
||||
app->recents[0].signal_mode = (uint8_t)TagTinkerSignalPP4;
|
||||
strncpy(app->recents[0].text, text_copy, TAGTINKER_PRESET_TEXT_LEN);
|
||||
}
|
||||
} else {
|
||||
/* New entry, shift others */
|
||||
if(app->recent_count < TAGTINKER_MAX_PRESETS) {
|
||||
app->recent_count++;
|
||||
}
|
||||
memmove(&app->recents[1], &app->recents[0], sizeof(app->recents[0]) * (size_t)(app->recent_count - 1));
|
||||
app->recents[0].width = app->esl_width;
|
||||
app->recents[0].height = app->esl_height;
|
||||
app->recents[0].page = app->img_page;
|
||||
app->recents[0].invert = app->invert_text;
|
||||
app->recents[0].color_clear = app->color_clear;
|
||||
app->recents[0].padding = app->text_padding_pct;
|
||||
app->recents[0].signal_mode = (uint8_t)TagTinkerSignalPP4;
|
||||
strncpy(app->recents[0].text, text, TAGTINKER_PRESET_TEXT_LEN - 1);
|
||||
app->recents[0].text[TAGTINKER_PRESET_TEXT_LEN - 1] = '\0';
|
||||
}
|
||||
|
||||
tagtinker_recents_save(app);
|
||||
}
|
||||
|
||||
static void app_free(TagTinkerApp* app) {
|
||||
furi_assert(app);
|
||||
|
||||
tagtinker_free_frame_sequence(app);
|
||||
|
||||
/* Views */
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewSubmenu);
|
||||
submenu_free(app->submenu);
|
||||
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewVarItemList);
|
||||
variable_item_list_free(app->var_item_list);
|
||||
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewTextInput);
|
||||
text_input_free(app->text_input);
|
||||
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewPopup);
|
||||
popup_free(app->popup);
|
||||
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewWidget);
|
||||
widget_free(app->widget);
|
||||
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewNumlock);
|
||||
numlock_input_free(app->numlock);
|
||||
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewTextBox);
|
||||
text_box_free(app->text_box);
|
||||
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewWarning);
|
||||
view_free(app->warning_view);
|
||||
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewTransmit);
|
||||
view_free(app->transmit_view);
|
||||
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewAbout);
|
||||
view_free(app->about_view);
|
||||
|
||||
view_dispatcher_free(app->view_dispatcher);
|
||||
scene_manager_free(app->scene_manager);
|
||||
|
||||
furi_thread_free(app->tx_thread);
|
||||
|
||||
furi_record_close(RECORD_GUI);
|
||||
furi_record_close(RECORD_NOTIFICATION);
|
||||
furi_record_close(RECORD_DIALOGS);
|
||||
if(app->bt) {
|
||||
furi_record_close(RECORD_BT);
|
||||
}
|
||||
|
||||
free(app);
|
||||
}
|
||||
|
||||
static bool tagtinker_try_consume_web_job(TagTinkerApp* app) {
|
||||
if(!app) return false;
|
||||
|
||||
Storage* storage = furi_record_open(RECORD_STORAGE);
|
||||
File* file = storage_file_alloc(storage);
|
||||
bool ok = false;
|
||||
|
||||
if(storage_file_open(file, TAGTINKER_WEB_JOB_PATH, FSAM_READ, FSOM_OPEN_EXISTING)) {
|
||||
char buf[512];
|
||||
uint16_t read = storage_file_read(file, buf, sizeof(buf) - 1U);
|
||||
buf[read] = '\0';
|
||||
storage_file_close(file);
|
||||
storage_common_remove(storage, TAGTINKER_WEB_JOB_PATH);
|
||||
|
||||
char barcode[TAGTINKER_BC_LEN + 1] = {0};
|
||||
char image_path[TAGTINKER_IMAGE_PATH_LEN + 1] = {0};
|
||||
unsigned width = 0U;
|
||||
unsigned height = 0U;
|
||||
unsigned page = 0U;
|
||||
|
||||
if(
|
||||
sscanf(
|
||||
buf,
|
||||
"%17[^|]|%u|%u|%u|%255[^\r\n]",
|
||||
barcode,
|
||||
&width,
|
||||
&height,
|
||||
&page,
|
||||
image_path) == 5) {
|
||||
int8_t target_index = tagtinker_ensure_target(app, barcode);
|
||||
if(target_index >= 0) {
|
||||
tagtinker_select_target(app, (uint8_t)target_index);
|
||||
app->img_page = (uint8_t)page;
|
||||
app->draw_x = 0U;
|
||||
app->draw_y = 0U;
|
||||
app->color_clear = false;
|
||||
tagtinker_prepare_bmp_tx(
|
||||
app,
|
||||
app->targets[target_index].plid,
|
||||
image_path,
|
||||
(uint16_t)width,
|
||||
(uint16_t)height,
|
||||
(uint8_t)page);
|
||||
app->tx_spam = false;
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
storage_file_free(file);
|
||||
furi_record_close(RECORD_STORAGE);
|
||||
return ok;
|
||||
}
|
||||
|
||||
static TagTinkerApp* app_alloc(void) {
|
||||
TagTinkerApp* app = malloc(sizeof(TagTinkerApp));
|
||||
memset(app, 0, sizeof(TagTinkerApp));
|
||||
@@ -587,71 +819,64 @@ static TagTinkerApp* app_alloc(void) {
|
||||
app->esl_height = 80;
|
||||
app->color_clear = false;
|
||||
app->invert_text = false;
|
||||
app->text_padding_pct = 0;
|
||||
strcpy(app->text_input_buf, "TagTinker");
|
||||
app->selected_target = -1;
|
||||
app->ble_sync_ready_target = -1;
|
||||
tagtinker_settings_load(app);
|
||||
tagtinker_targets_load(app);
|
||||
tagtinker_recents_load(app);
|
||||
|
||||
/* Scene manager */
|
||||
app->gui = furi_record_open(RECORD_GUI);
|
||||
app->notifications = furi_record_open(RECORD_NOTIFICATION);
|
||||
app->dialogs = furi_record_open(RECORD_DIALOGS);
|
||||
app->bt = furi_record_open(RECORD_BT);
|
||||
|
||||
/* Momentum safety: Ensure radio is idle on startup */
|
||||
bt_disconnect(app->bt);
|
||||
bt_profile_restore_default(app->bt);
|
||||
|
||||
app->view_dispatcher = view_dispatcher_alloc();
|
||||
app->scene_manager = scene_manager_alloc(&tagtinker_scene_handlers, app);
|
||||
|
||||
/* View dispatcher */
|
||||
app->view_dispatcher = view_dispatcher_alloc();
|
||||
view_dispatcher_set_event_callback_context(app->view_dispatcher, app);
|
||||
view_dispatcher_set_custom_event_callback(app->view_dispatcher, custom_event_cb);
|
||||
view_dispatcher_set_navigation_event_callback(app->view_dispatcher, navigation_cb);
|
||||
view_dispatcher_set_tick_event_callback(app->view_dispatcher, tick_cb, 50);
|
||||
view_dispatcher_set_custom_event_callback(app->view_dispatcher, custom_event_cb);
|
||||
|
||||
/* GUI */
|
||||
app->gui = furi_record_open(RECORD_GUI);
|
||||
view_dispatcher_attach_to_gui(app->view_dispatcher, app->gui, ViewDispatcherTypeFullscreen);
|
||||
|
||||
/* Notifications */
|
||||
app->notifications = furi_record_open(RECORD_NOTIFICATION);
|
||||
app->bt = furi_record_open(RECORD_BT);
|
||||
|
||||
/* Views */
|
||||
app->submenu = submenu_alloc();
|
||||
view_dispatcher_add_view(
|
||||
app->view_dispatcher, TagTinkerViewSubmenu, submenu_get_view(app->submenu));
|
||||
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewSubmenu, submenu_get_view(app->submenu));
|
||||
|
||||
app->var_item_list = variable_item_list_alloc();
|
||||
view_dispatcher_add_view(
|
||||
app->view_dispatcher, TagTinkerViewVarItemList,
|
||||
variable_item_list_get_view(app->var_item_list));
|
||||
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewVarItemList, variable_item_list_get_view(app->var_item_list));
|
||||
|
||||
app->text_input = text_input_alloc();
|
||||
view_dispatcher_add_view(
|
||||
app->view_dispatcher, TagTinkerViewTextInput, text_input_get_view(app->text_input));
|
||||
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewTextInput, text_input_get_view(app->text_input));
|
||||
|
||||
app->popup = popup_alloc();
|
||||
view_dispatcher_add_view(
|
||||
app->view_dispatcher, TagTinkerViewPopup, popup_get_view(app->popup));
|
||||
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewPopup, popup_get_view(app->popup));
|
||||
|
||||
app->widget = widget_alloc();
|
||||
view_dispatcher_add_view(
|
||||
app->view_dispatcher, TagTinkerViewWidget, widget_get_view(app->widget));
|
||||
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewWidget, widget_get_view(app->widget));
|
||||
|
||||
app->numlock = numlock_input_alloc();
|
||||
view_dispatcher_add_view(
|
||||
app->view_dispatcher, TagTinkerViewNumlock, numlock_input_get_view(app->numlock));
|
||||
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewNumlock, numlock_input_get_view(app->numlock));
|
||||
|
||||
app->text_box = text_box_alloc();
|
||||
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewTextBox, text_box_get_view(app->text_box));
|
||||
|
||||
app->warning_view = view_alloc();
|
||||
view_dispatcher_add_view(
|
||||
app->view_dispatcher, TagTinkerViewWarning, app->warning_view);
|
||||
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewWarning, app->warning_view);
|
||||
|
||||
app->transmit_view = view_alloc();
|
||||
view_dispatcher_add_view(
|
||||
app->view_dispatcher, TagTinkerViewTransmit, app->transmit_view);
|
||||
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewTransmit, app->transmit_view);
|
||||
|
||||
app->about_view = view_alloc();
|
||||
view_dispatcher_add_view(
|
||||
app->view_dispatcher, TagTinkerViewAbout, app->about_view);
|
||||
view_dispatcher_add_view(app->view_dispatcher, TagTinkerViewAbout, app->about_view);
|
||||
|
||||
app->dialogs = furi_record_open(RECORD_DIALOGS);
|
||||
|
||||
/* Allocate Thread and Timer for animations and TX */
|
||||
/* TX Thread */
|
||||
app->tx_thread = furi_thread_alloc();
|
||||
furi_thread_set_name(app->tx_thread, "TagTinkerTx");
|
||||
furi_thread_set_stack_size(app->tx_thread, 4096);
|
||||
@@ -661,53 +886,21 @@ static TagTinkerApp* app_alloc(void) {
|
||||
return app;
|
||||
}
|
||||
|
||||
static void app_free(TagTinkerApp* app) {
|
||||
tagtinker_ir_deinit();
|
||||
|
||||
tagtinker_free_frame_sequence(app);
|
||||
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewSubmenu);
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewVarItemList);
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewTextInput);
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewPopup);
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewWidget);
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewNumlock);
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewWarning);
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewTransmit);
|
||||
view_dispatcher_remove_view(app->view_dispatcher, TagTinkerViewAbout);
|
||||
|
||||
submenu_free(app->submenu);
|
||||
variable_item_list_free(app->var_item_list);
|
||||
text_input_free(app->text_input);
|
||||
popup_free(app->popup);
|
||||
widget_free(app->widget);
|
||||
numlock_input_free(app->numlock);
|
||||
|
||||
view_dispatcher_free(app->view_dispatcher);
|
||||
scene_manager_free(app->scene_manager);
|
||||
|
||||
furi_record_close(RECORD_GUI);
|
||||
furi_record_close(RECORD_NOTIFICATION);
|
||||
furi_record_close(RECORD_DIALOGS);
|
||||
if(app->bt) {
|
||||
furi_record_close(RECORD_BT);
|
||||
}
|
||||
|
||||
view_free(app->warning_view);
|
||||
view_free(app->transmit_view);
|
||||
view_free(app->about_view);
|
||||
furi_thread_free(app->tx_thread);
|
||||
|
||||
free(app);
|
||||
}
|
||||
|
||||
int32_t tagtinker_app_main(void* p) {
|
||||
UNUSED(p);
|
||||
TagTinkerApp* app = app_alloc();
|
||||
scene_manager_next_scene(
|
||||
app->scene_manager,
|
||||
app->show_startup_warning ? TagTinkerSceneWarning : TagTinkerSceneMainMenu);
|
||||
bool web_job_ready = tagtinker_try_consume_web_job(app);
|
||||
|
||||
if(web_job_ready) {
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerSceneTransmit);
|
||||
} else if(app->show_startup_warning) {
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerSceneWarning);
|
||||
} else {
|
||||
scene_manager_next_scene(app->scene_manager, TagTinkerSceneMainMenu);
|
||||
}
|
||||
|
||||
view_dispatcher_run(app->view_dispatcher);
|
||||
|
||||
app_free(app);
|
||||
return 0;
|
||||
}
|
||||
|
||||
+22
-7
@@ -14,6 +14,7 @@
|
||||
#include <gui/modules/variable_item_list.h>
|
||||
#include <gui/modules/popup.h>
|
||||
#include <gui/modules/widget.h>
|
||||
#include <gui/modules/text_box.h>
|
||||
#include <dialogs/dialogs.h>
|
||||
#include <notification/notification_messages.h>
|
||||
#include <storage/storage.h>
|
||||
@@ -28,7 +29,7 @@
|
||||
|
||||
#define TAGTINKER_TAG "TagTinker"
|
||||
#define TAGTINKER_DISPLAY_NAME "TagTinker"
|
||||
#define TAGTINKER_VERSION "1.3"
|
||||
#define TAGTINKER_VERSION "2.0"
|
||||
#define TAGTINKER_BC_LEN 17
|
||||
#define TAGTINKER_HEX_LEN 64
|
||||
#define TAGTINKER_MAX_TARGETS 16
|
||||
@@ -47,7 +48,6 @@ typedef enum {
|
||||
|
||||
typedef enum {
|
||||
TagTinkerSignalPP4 = 0,
|
||||
TagTinkerSignalPP16,
|
||||
} TagTinkerSignalMode;
|
||||
|
||||
typedef struct {
|
||||
@@ -84,6 +84,7 @@ typedef enum {
|
||||
TagTinkerViewPopup,
|
||||
TagTinkerViewWidget,
|
||||
TagTinkerViewNumlock,
|
||||
TagTinkerViewTextBox,
|
||||
TagTinkerViewTargetActions,
|
||||
TagTinkerViewWarning,
|
||||
TagTinkerViewTransmit,
|
||||
@@ -113,6 +114,7 @@ struct TagTinkerApp {
|
||||
Popup* popup;
|
||||
Widget* widget;
|
||||
NumlockInput* numlock;
|
||||
TextBox* text_box;
|
||||
View* target_actions_view;
|
||||
View* warning_view;
|
||||
View* transmit_view;
|
||||
@@ -161,17 +163,20 @@ struct TagTinkerApp {
|
||||
size_t frame_seq_count;
|
||||
bool invert_text;
|
||||
bool color_clear;
|
||||
uint8_t text_padding_pct;
|
||||
|
||||
/* Saved presets */
|
||||
/* Saved recents */
|
||||
struct {
|
||||
uint16_t width;
|
||||
uint16_t height;
|
||||
uint8_t page;
|
||||
bool invert;
|
||||
bool color_clear;
|
||||
uint8_t padding;
|
||||
uint8_t signal_mode;
|
||||
char text[TAGTINKER_PRESET_TEXT_LEN];
|
||||
} presets[TAGTINKER_MAX_PRESETS];
|
||||
uint8_t preset_count;
|
||||
} recents[TAGTINKER_MAX_PRESETS];
|
||||
uint8_t recent_count;
|
||||
|
||||
TagTinkerSyncedImage synced_images[TAGTINKER_MAX_SYNCED_IMAGES];
|
||||
uint8_t synced_image_count;
|
||||
@@ -191,11 +196,17 @@ struct TagTinkerApp {
|
||||
/* Indicates which mode triggered raw cmd (0=broadcast, 1=targeted) */
|
||||
uint8_t raw_mode;
|
||||
|
||||
/* Android BLE sync state */
|
||||
/* Browser BLE sync state */
|
||||
Bt* bt;
|
||||
FuriHalBleProfileBase* ble_serial;
|
||||
BtStatus ble_status;
|
||||
bool ble_sync_active;
|
||||
bool ble_sync_start_pending;
|
||||
bool ble_serial_configured;
|
||||
uint32_t ble_total_rx;
|
||||
uint8_t ble_last_bytes[3];
|
||||
bool ble_sync_auto_send_pending;
|
||||
uint8_t ble_sync_auto_send_delay;
|
||||
uint16_t ble_synced_lines;
|
||||
char ble_status_text[32];
|
||||
char ble_rx_line[1024];
|
||||
@@ -211,6 +222,7 @@ struct TagTinkerApp {
|
||||
uint32_t ble_sync_expected_bytes;
|
||||
uint32_t ble_sync_received_bytes;
|
||||
uint16_t ble_sync_last_chunk;
|
||||
File* ble_sync_file;
|
||||
uint16_t ble_sync_last_completed_chunks;
|
||||
bool ble_sync_compact_protocol;
|
||||
bool ble_sync_last_compact_protocol;
|
||||
@@ -242,7 +254,7 @@ typedef enum {
|
||||
} TagTinkerTargetActionItem;
|
||||
|
||||
void tagtinker_target_refresh_profile(TagTinkerTarget* target);
|
||||
void tagtinker_target_set_default_name(TagTinkerTarget* target);
|
||||
void tagtinker_target_set_default_name(TagTinkerApp* app, TagTinkerTarget* target);
|
||||
bool tagtinker_target_supports_graphics(const TagTinkerTarget* target);
|
||||
bool tagtinker_target_supports_accent(const TagTinkerTarget* target);
|
||||
const char* tagtinker_profile_kind_label(TagTinkerTagKind kind);
|
||||
@@ -271,3 +283,6 @@ void tagtinker_settings_load(TagTinkerApp* app);
|
||||
bool tagtinker_settings_save(const TagTinkerApp* app);
|
||||
void tagtinker_targets_load(TagTinkerApp* app);
|
||||
bool tagtinker_targets_save(const TagTinkerApp* app);
|
||||
void tagtinker_recents_load(TagTinkerApp* app);
|
||||
bool tagtinker_recents_save(const TagTinkerApp* app);
|
||||
void tagtinker_recents_add(TagTinkerApp* app, const char* text);
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TagTinker Image Converter</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--surface: #18181b;
|
||||
--border: #27272a;
|
||||
--primary: #FF8A00; /* Flipper Orange */
|
||||
--primary-hover: #ff9d2e;
|
||||
--text: #fafafa;
|
||||
--text-dim: #a1a1aa;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Inter', -apple-system, sans-serif; }
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -1px;
|
||||
color: var(--primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
header p { color: var(--text-dim); margin-top: 0.5rem; }
|
||||
|
||||
.dropzone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(255,255,255,0.02);
|
||||
margin-bottom: 2rem;
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropzone:hover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(255,138,0,0.05);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.dropzone input {
|
||||
position: absolute;
|
||||
width: 0.1px;
|
||||
height: 0.1px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
label { font-size: 0.875rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
select, input[type="file"] {
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
select:focus { border-color: var(--primary); }
|
||||
|
||||
.preview-section {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
canvas {
|
||||
image-rendering: pixelated;
|
||||
max-width: 100%;
|
||||
background: white;
|
||||
border: 1px solid #333;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
background: var(--primary);
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button:hover { background: var(--primary-hover); }
|
||||
button:disabled { background: var(--border); color: var(--text-dim); cursor: not-allowed; }
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>TagTinker Image Converter</h1>
|
||||
<p>Prepare 1-bit BMP assets for educational ESL display experiments on hardware you own.</p>
|
||||
</header>
|
||||
|
||||
<label class="dropzone" id="dropzone">
|
||||
<input type="file" id="fileInput" accept="image/*">
|
||||
<div id="dropText">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="2" style="margin-bottom: 1rem;">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
|
||||
</svg><br>
|
||||
<strong>Click or Drag & Drop</strong><br>
|
||||
<span style="font-size: 0.9em; color: var(--text-dim);">JPEG, PNG, WebP accepted</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label>Target Resolution</label>
|
||||
<select id="resSelect">
|
||||
<option value="128x64">128x64 (Standard Small)</option>
|
||||
<option value="200x90">200x90 (Medium Tag)</option>
|
||||
<option value="250x122">250x122 (Large Tag 1)</option>
|
||||
<option value="400x300">400x300 (Large E-Paper)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>Dithering Engine</label>
|
||||
<select id="ditherSelect">
|
||||
<option value="floyd">Floyd-Steinberg (High Detail)</option>
|
||||
<option value="atkinson">Atkinson (High Contrast)</option>
|
||||
<option value="threshold">Binary Threshold (Logos/Text)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-section" id="previewArea" style="display: none;">
|
||||
<label>Pixel Perfect Preview</label>
|
||||
<canvas id="canvasPreview"></canvas>
|
||||
</div>
|
||||
|
||||
<button id="downloadBtn" disabled>Download BMP Asset</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const dropzone = document.getElementById('dropzone');
|
||||
const resSelect = document.getElementById('resSelect');
|
||||
const ditherSelect = document.getElementById('ditherSelect');
|
||||
const canvas = document.getElementById('canvasPreview');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const previewArea = document.getElementById('previewArea');
|
||||
const downloadBtn = document.getElementById('downloadBtn');
|
||||
|
||||
let currentImage = null;
|
||||
let lastPayloadBuffer = null;
|
||||
|
||||
// UI Handlers
|
||||
dropzone.addEventListener('dragover', e => { e.preventDefault(); dropzone.style.borderColor = 'var(--primary)'; });
|
||||
dropzone.addEventListener('dragleave', e => { e.preventDefault(); dropzone.style.borderColor = 'var(--border)'; });
|
||||
dropzone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
dropzone.style.borderColor = 'var(--border)';
|
||||
if(e.dataTransfer.files.length) processFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
fileInput.addEventListener('change', e => { if(e.target.files.length) processFile(e.target.files[0]); });
|
||||
|
||||
resSelect.addEventListener('change', updatePreview);
|
||||
ditherSelect.addEventListener('change', updatePreview);
|
||||
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
if(!lastPayloadBuffer) return;
|
||||
const [w, h] = resSelect.value.split('x').map(Number);
|
||||
const blob = new Blob([lastPayloadBuffer], { type: "image/bmp" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `esl_study_${w}x${h}.bmp`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
function processFile(file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
currentImage = img;
|
||||
updatePreview();
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
if(!currentImage) return;
|
||||
|
||||
const [tw, th] = resSelect.value.split('x').map(Number);
|
||||
canvas.width = tw;
|
||||
canvas.height = th;
|
||||
previewArea.style.display = 'flex';
|
||||
|
||||
// Calculate scaling to 'Cover' the target resolution
|
||||
const scale = Math.max(tw / currentImage.width, th / currentImage.height);
|
||||
const sw = currentImage.width * scale;
|
||||
const sh = currentImage.height * scale;
|
||||
const offsetX = (tw - sw) / 2;
|
||||
const offsetY = (th - sh) / 2;
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, tw, th);
|
||||
ctx.drawImage(currentImage, offsetX, offsetY, sw, sh);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, tw, th);
|
||||
const data = imageData.data;
|
||||
|
||||
// Greyscale conversion
|
||||
const greyscale = new Float32Array(tw * th);
|
||||
for(let i=0; i<data.length; i+=4) {
|
||||
// Luminance
|
||||
greyscale[i/4] = 0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2];
|
||||
}
|
||||
|
||||
const method = ditherSelect.value;
|
||||
|
||||
if(method === 'threshold') {
|
||||
for(let i=0; i<greyscale.length; i++) {
|
||||
const c = greyscale[i] < 128 ? 0 : 255;
|
||||
data[i*4] = data[i*4+1] = data[i*4+2] = c;
|
||||
}
|
||||
} else if (method === 'floyd') {
|
||||
for(let y=0; y<th; y++) {
|
||||
for(let x=0; x<tw; x++) {
|
||||
const idx = y*tw + x;
|
||||
const oldPixel = greyscale[idx];
|
||||
const newPixel = oldPixel < 128 ? 0 : 255;
|
||||
|
||||
data[idx*4] = data[idx*4+1] = data[idx*4+2] = newPixel;
|
||||
const err = oldPixel - newPixel;
|
||||
|
||||
if(x+1 < tw) greyscale[idx+1] += err * 7/16;
|
||||
if(y+1 < th) {
|
||||
if(x-1 >= 0) greyscale[idx+tw-1] += err * 3/16;
|
||||
greyscale[idx+tw] += err * 5/16;
|
||||
if(x+1 < tw) greyscale[idx+tw+1] += err * 1/16;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (method === 'atkinson') {
|
||||
for(let y=0; y<th; y++) {
|
||||
for(let x=0; x<tw; x++) {
|
||||
const idx = y*tw + x;
|
||||
const oldPixel = greyscale[idx];
|
||||
const newPixel = oldPixel < 128 ? 0 : 255;
|
||||
|
||||
data[idx*4] = data[idx*4+1] = data[idx*4+2] = newPixel;
|
||||
const err = Math.floor((oldPixel - newPixel)/8);
|
||||
|
||||
if(x+1 < tw) greyscale[idx+1] += err;
|
||||
if(x+2 < tw) greyscale[idx+2] += err;
|
||||
if(y+1 < th) {
|
||||
if(x-1 >= 0) greyscale[idx+tw-1] += err;
|
||||
greyscale[idx+tw] += err;
|
||||
if(x+1 < tw) greyscale[idx+tw+1] += err;
|
||||
}
|
||||
if(y+2 < th) greyscale[idx+tw*2] += err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
generateBMP();
|
||||
downloadBtn.disabled = false;
|
||||
usbBtn.disabled = false;
|
||||
}
|
||||
|
||||
function generateBMP() {
|
||||
const [w, h] = resSelect.value.split('x').map(Number);
|
||||
const imageData = ctx.getImageData(0, 0, w, h).data;
|
||||
|
||||
const binaryPixels = new Uint8Array(w * h);
|
||||
for(let i=0; i<binaryPixels.length; i++) {
|
||||
// Determine if pixel is white (1) or black (0) for BMP
|
||||
binaryPixels[i] = imageData[i*4] > 127 ? 1 : 0;
|
||||
}
|
||||
|
||||
const rowStride = Math.floor((w + 31) / 32) * 4;
|
||||
const pixelDataSize = rowStride * h;
|
||||
const fileSize = 14 + 40 + 8 + pixelDataSize;
|
||||
|
||||
const buffer = new ArrayBuffer(fileSize);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// BMP Header
|
||||
view.setUint8(0, 0x42); // 'B'
|
||||
view.setUint8(1, 0x4D); // 'M'
|
||||
view.setUint32(2, fileSize, true);
|
||||
view.setUint32(6, 0, true);
|
||||
view.setUint32(10, 62, true); // Offset to Data
|
||||
|
||||
// DIB Header
|
||||
view.setUint32(14, 40, true);
|
||||
view.setUint32(18, w, true);
|
||||
view.setInt32(22, h, true); // Positive = Bottom-Up
|
||||
view.setUint16(26, 1, true); // Color Planes
|
||||
view.setUint16(28, 1, true); // Bits per pixel (1-bit)
|
||||
view.setUint32(30, 0, true); // BI_RGB
|
||||
view.setUint32(34, pixelDataSize, true);
|
||||
view.setUint32(38, 2835, true);
|
||||
view.setUint32(42, 2835, true);
|
||||
view.setUint32(46, 2, true);
|
||||
view.setUint32(50, 0, true);
|
||||
|
||||
// Color Table
|
||||
view.setUint32(54, 0x00000000, true); // Color 0: Black
|
||||
view.setUint32(58, 0x00FFFFFF, true); // Color 1: White
|
||||
|
||||
// Pixel Data
|
||||
const dataView = new Uint8Array(buffer, 62);
|
||||
for (let y = 0; y < h; y++) {
|
||||
// Bottom-Up
|
||||
const srcY = h - 1 - y;
|
||||
for (let x = 0; x < w; x++) {
|
||||
const px = binaryPixels[srcY * w + x];
|
||||
if (px === 1) { // Set bit if White
|
||||
const byteIndex = y * rowStride + Math.floor(x / 8);
|
||||
const bitShift = 7 - (x % 8);
|
||||
dataView[byteIndex] |= (1 << bitShift);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blob = new Blob([buffer], { type: "image/bmp" });
|
||||
lastPayloadBuffer = buffer;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -173,10 +173,10 @@ NumlockInput* numlock_input_alloc(void) {
|
||||
numlock->view,
|
||||
NumlockModel * m,
|
||||
{
|
||||
m->prefix = 'N';
|
||||
m->prefix = 'A';
|
||||
memset(m->digits, 0, NUM_DIGITS);
|
||||
m->digits[0] = 4;
|
||||
m->cursor = 1;
|
||||
m->digits[0] = 0;
|
||||
m->cursor = 0;
|
||||
},
|
||||
true);
|
||||
|
||||
@@ -202,10 +202,10 @@ void numlock_input_reset(NumlockInput* numlock) {
|
||||
numlock->view,
|
||||
NumlockModel * m,
|
||||
{
|
||||
m->prefix = 'N';
|
||||
m->prefix = 'A';
|
||||
memset(m->digits, 0, NUM_DIGITS);
|
||||
m->digits[0] = 4;
|
||||
m->cursor = 1;
|
||||
m->digits[0] = 0;
|
||||
m->cursor = 0;
|
||||
},
|
||||
true);
|
||||
}
|
||||
|
||||
+25
-9
@@ -137,14 +137,22 @@ static inline void set_region_pixel(
|
||||
* No outer margin is added.
|
||||
*/
|
||||
static inline void render_text_ex(uint8_t* buf, uint16_t w, uint16_t h,
|
||||
const char* text, uint8_t bg_val, uint8_t fg_val) {
|
||||
memset(buf, bg_val, w * h);
|
||||
const char* text, uint8_t bg_val, uint8_t fg_val, uint8_t padding_pct) {
|
||||
memset(buf, bg_val, (size_t)w * h);
|
||||
|
||||
size_t len = strlen(text);
|
||||
if(len == 0) return;
|
||||
|
||||
uint16_t scale_w = w / (len * (FONT_CHAR_W + 1));
|
||||
uint16_t scale_h = h / FONT_CHAR_H;
|
||||
if(padding_pct > 45) padding_pct = 45;
|
||||
|
||||
uint16_t pad_w = (w * padding_pct) / 100;
|
||||
uint16_t pad_h = (h * padding_pct) / 100;
|
||||
|
||||
uint16_t avail_w = (w > 2 * pad_w) ? (uint16_t)(w - 2 * pad_w) : 1U;
|
||||
uint16_t avail_h = (h > 2 * pad_h) ? (uint16_t)(h - 2 * pad_h) : 1U;
|
||||
|
||||
uint16_t scale_w = avail_w / (len * (FONT_CHAR_W + 1));
|
||||
uint16_t scale_h = avail_h / FONT_CHAR_H;
|
||||
|
||||
if(scale_w < 1) scale_w = 1;
|
||||
if(scale_h < 1) scale_h = 1;
|
||||
@@ -179,7 +187,6 @@ static inline void render_text_ex(uint8_t* buf, uint16_t w, uint16_t h,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static inline void render_text_region_ex(
|
||||
uint8_t* buf,
|
||||
uint16_t full_w,
|
||||
@@ -188,14 +195,23 @@ static inline void render_text_region_ex(
|
||||
uint16_t region_h,
|
||||
const char* text,
|
||||
uint8_t bg_val,
|
||||
uint8_t fg_val) {
|
||||
uint8_t fg_val,
|
||||
uint8_t padding_pct) {
|
||||
memset(buf, bg_val, full_w * region_h);
|
||||
|
||||
size_t len = strlen(text);
|
||||
if(len == 0) return;
|
||||
|
||||
uint16_t scale_w = full_w / (len * (FONT_CHAR_W + 1));
|
||||
uint16_t scale_h = full_h / FONT_CHAR_H;
|
||||
if(padding_pct > 45) padding_pct = 45; /* Max 90% total padding */
|
||||
|
||||
uint16_t pad_w = (full_w * padding_pct) / 100;
|
||||
uint16_t pad_h = (full_h * padding_pct) / 100;
|
||||
|
||||
uint16_t avail_w = (full_w > 2 * pad_w) ? (uint16_t)(full_w - 2 * pad_w) : 1U;
|
||||
uint16_t avail_h = (full_h > 2 * pad_h) ? (uint16_t)(full_h - 2 * pad_h) : 1U;
|
||||
|
||||
uint16_t scale_w = avail_w / (len * (FONT_CHAR_W + 1));
|
||||
uint16_t scale_h = avail_h / FONT_CHAR_H;
|
||||
|
||||
if(scale_w < 1) scale_w = 1;
|
||||
if(scale_h < 1) scale_h = 1;
|
||||
@@ -230,7 +246,7 @@ static inline void render_text_region_ex(
|
||||
|
||||
/* Convenience: normal polarity (white bg=1, black text=0) */
|
||||
static inline void render_text(uint8_t* buf, uint16_t w, uint16_t h, const char* text) {
|
||||
render_text_ex(buf, w, h, text, 1, 0);
|
||||
render_text_ex(buf, w, h, text, 1, 0, 0);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user