From f8b877d5559203b2ebc3fde214562b0d922c01b5 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 11 Aug 2026 11:27:20 +0200 Subject: [PATCH] Phase 10-13: PWA fertig, blockierte Phasen vorbereitet PWA laeuft: Manifest, Symbole in allen noetigen Groessen und die iOS-Angaben, die Apple statt des Manifests auswertet. Das Startsymbol ist bewusst neutral - die Vier Ringe auf einem Homescreen waeren nach aussen sichtbar und fielen nicht mehr unter die private Nutzung. Vorbereitet, aber nicht ausgefuehrt: Capacitor-Konfiguration, ein Ablage-Adapter fuer Schluesselbund und Keystore (greift defensiv auf die Laufzeit zu, damit die App ohne das Plugin unveraendert weiterlaeuft), die Pfad-Freigabeliste fuer den Reverse Proxy und das Geruest der FMM003-Zuordnungstabelle. Die QR-Seite fuer die Einrichtung war zunaechst ein Fehlgriff: Ich hatte den QR-Erzeuger selbst geschrieben. Der Vergleich gegen eine erprobte Implementierung zeigte 1239 abweichende Module von 3249 - der Code waere unlesbar gewesen. Jetzt liegt eine bewaehrte Bibliothek (MIT, 57 KB) neben der Seite im eigenen Home Assistant. Das erfuellt die eigentliche Anforderung genauso: kein Netzzugriff, der Token verlaesst das eigene Netz nicht. Geprueft ueber einen Umlauf - 233 Zeichen hinein, identisch wieder heraus. --- companion-app/capacitor.config.ts | 51 + companion-app/index.html | 9 + companion-app/public/apple-touch-icon.png | Bin 0 -> 7763 bytes companion-app/public/manifest.webmanifest | 33 + companion-app/public/symbol-192.png | Bin 0 -> 8401 bytes companion-app/public/symbol-512.png | Bin 0 -> 18438 bytes companion-app/public/symbol.svg | 5 + companion-app/src/api/ablageNativ.ts | 88 + companion-app/src/api/index.ts | 1 + companion-app/src/main.tsx | 5 + homeassistant/FMM003_MAPPING.md | 136 ++ homeassistant/REVERSE_PROXY.md | 126 ++ homeassistant/www/dm360-qr.html | 115 ++ homeassistant/www/dm360-qrcode-lib.js | 2297 +++++++++++++++++++++ 14 files changed, 2866 insertions(+) create mode 100644 companion-app/capacitor.config.ts create mode 100644 companion-app/public/apple-touch-icon.png create mode 100644 companion-app/public/manifest.webmanifest create mode 100644 companion-app/public/symbol-192.png create mode 100644 companion-app/public/symbol-512.png create mode 100644 companion-app/public/symbol.svg create mode 100644 companion-app/src/api/ablageNativ.ts create mode 100644 homeassistant/FMM003_MAPPING.md create mode 100644 homeassistant/REVERSE_PROXY.md create mode 100644 homeassistant/www/dm360-qr.html create mode 100644 homeassistant/www/dm360-qrcode-lib.js diff --git a/companion-app/capacitor.config.ts b/companion-app/capacitor.config.ts new file mode 100644 index 0000000..8f906c1 --- /dev/null +++ b/companion-app/capacitor.config.ts @@ -0,0 +1,51 @@ +/** + * Capacitor-Konfiguration für die native Hülle. + * + * **Noch nicht in Betrieb.** Die Pakete sind bewusst nicht installiert; diese + * Datei liegt fertig da, damit die Inbetriebnahme nur noch aus den Befehlen in + * `../UMSETZUNGSPLAN.md` Phase 10 Stufe 2 besteht: + * + * npm i -D @capacitor/cli + * npm i @capacitor/core @capacitor/ios @capacitor/android + * npx cap add ios && npx cap add android + * npm run build && npx cap sync + * npx cap open ios # in Xcode signieren und aufs Gerät bauen + * + * Nur Sideload, nie App Store: die Audi-Schrift und die Typenschilder sind + * ausschließlich für diese private Installation freigegeben. + * + * Der Typ ist absichtlich nicht aus @capacitor/cli importiert — das Paket + * fehlt ja noch, und ein toter Import würde die Typprüfung brechen. + */ + +interface CapacitorKonfiguration { + appId: string + appName: string + webDir: string + server?: { androidScheme?: string; iosScheme?: string; cleartext?: boolean } + ios?: { contentInset?: string; backgroundColor?: string } + android?: { backgroundColor?: string } +} + +const konfiguration: CapacitorKonfiguration = { + appId: "app.datametric360", + appName: "DataMetric360", + webDir: "dist", + server: { + // https, damit die App als sicherer Ursprung gilt — sonst verweigern + // Browser-Bausteine wie die Zwischenablage ihren Dienst. + androidScheme: "https", + iosScheme: "https", + }, + ios: { + // Die App zeichnet bis unter die Statusleiste und hält den sicheren + // Bereich selbst über env(safe-area-inset-*) frei. + contentInset: "never", + backgroundColor: "#161b23", + }, + android: { + backgroundColor: "#161b23", + }, +} + +export default konfiguration diff --git a/companion-app/index.html b/companion-app/index.html index 1599a53..8862b87 100644 --- a/companion-app/index.html +++ b/companion-app/index.html @@ -8,6 +8,15 @@ /> + + + + + + + DataMetric360 diff --git a/companion-app/public/apple-touch-icon.png b/companion-app/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..29cdfe1dddaf27074a2239fa6f3c8916fc95a838 GIT binary patch literal 7763 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 TP14ri{HwxK71_ckCPDuTm+y>T literal 0 HcmV?d00001 diff --git a/companion-app/public/manifest.webmanifest b/companion-app/public/manifest.webmanifest new file mode 100644 index 0000000..aae28cb --- /dev/null +++ b/companion-app/public/manifest.webmanifest @@ -0,0 +1,33 @@ +{ + "name": "DataMetric360", + "short_name": "DM360", + "description": "Fahrzeugdaten, Fahrtenbuch und Tankvorgänge vom eigenen Server.", + "lang": "de", + "dir": "ltr", + "start_url": "./", + "scope": "./", + "display": "standalone", + "orientation": "portrait-primary", + "background_color": "#161b23", + "theme_color": "#161b23", + "icons": [ + { + "src": "./symbol.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "./symbol-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "./symbol-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/companion-app/public/symbol-192.png b/companion-app/public/symbol-192.png new file mode 100644 index 0000000000000000000000000000000000000000..108e79b867181495848c65ef66fa7dc1156c9067 GIT binary patch literal 8401 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 literal 0 HcmV?d00001 diff --git a/companion-app/public/symbol.svg b/companion-app/public/symbol.svg new file mode 100644 index 0000000..764a621 --- /dev/null +++ b/companion-app/public/symbol.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/companion-app/src/api/ablageNativ.ts b/companion-app/src/api/ablageNativ.ts new file mode 100644 index 0000000..6de861e --- /dev/null +++ b/companion-app/src/api/ablageNativ.ts @@ -0,0 +1,88 @@ +/* ================================================================ + Sichere Ablage auf dem Gerät (iOS Keychain / Android Keystore) + ================================================================ + Der Zugangstoken hat vollen Zugriff auf die Home-Assistant-Schnittstelle. + Im Browser bleibt nur localStorage; in der nativen Hülle gehört er in den + Schlüsselbund des Betriebssystems. + + **Noch nicht in Betrieb**: das Plugin ist bewusst keine Abhängigkeit, + solange die Capacitor-Hülle nicht gebaut ist. Der Adapter greift deshalb + defensiv auf die Laufzeit zu, statt zu importieren — fehlt das Plugin, + bleibt es bei der Browser-Variante, und die App läuft trotzdem. + + In Betrieb nehmen (UMSETZUNGSPLAN.md Phase 10 Stufe 2): + npm i @aparajita/capacitor-secure-storage + Danach ruft main.tsx `nativeAblageEinrichten()` auf. */ + +import { BrowserAblage, ablageSetzen, umgebungErkennen, type Ablage } from "./umgebung.ts"; + +/** Form des Plugins, so weit die App sie braucht. */ +interface SicherheitsPlugin { + get(optionen: { key: string }): Promise<{ value: string | null }>; + set(optionen: { key: string; value: string }): Promise; + remove(optionen: { key: string }): Promise; +} + +function pluginHolen(): SicherheitsPlugin | null { + if (typeof window === "undefined") return null; + const global = window as unknown as { + Capacitor?: { Plugins?: Record }; + }; + const plugin = global.Capacitor?.Plugins?.["SecureStorage"]; + if (!plugin) return null; + const kandidat = plugin as Partial; + return typeof kandidat.get === "function" && + typeof kandidat.set === "function" && + typeof kandidat.remove === "function" + ? (plugin as SicherheitsPlugin) + : null; +} + +export class NativeAblage implements Ablage { + readonly #plugin: SicherheitsPlugin; + /* Fällt einzeln auf den Browser zurück, wenn ein Aufruf scheitert - besser + eine Sitzung ohne Schlüsselbund als eine App, die nicht startet. */ + readonly #ersatz = new BrowserAblage(); + + constructor(plugin: SicherheitsPlugin) { + this.#plugin = plugin; + } + + async lesen(schluessel: string): Promise { + try { + const { value } = await this.#plugin.get({ key: schluessel }); + return value ?? null; + } catch { + return this.#ersatz.lesen(schluessel); + } + } + + async schreiben(schluessel: string, wert: string): Promise { + try { + await this.#plugin.set({ key: schluessel, value: wert }); + } catch { + await this.#ersatz.schreiben(schluessel, wert); + } + } + + async loeschen(schluessel: string): Promise { + try { + await this.#plugin.remove({ key: schluessel }); + } catch { + await this.#ersatz.loeschen(schluessel); + } + } +} + +/** + * Schaltet auf die sichere Ablage um, sofern die App nativ läuft und das + * Plugin vorhanden ist. Gibt zurück, ob umgeschaltet wurde — praktisch für + * eine Anzeige in den Einstellungen. + */ +export function nativeAblageEinrichten(): boolean { + if (umgebungErkennen() !== "capacitor") return false; + const plugin = pluginHolen(); + if (!plugin) return false; + ablageSetzen(new NativeAblage(plugin)); + return true; +} diff --git a/companion-app/src/api/index.ts b/companion-app/src/api/index.ts index 42ee522..0ef1451 100644 --- a/companion-app/src/api/index.ts +++ b/companion-app/src/api/index.ts @@ -11,6 +11,7 @@ export * from "./umgebung.ts"; export * from "./rest.ts"; export * from "./live.ts"; export * from "./warteschlange.ts"; +export * from "./ablageNativ.ts"; import { ENTITAETEN } from "./types.ts"; import type { Fahrt, Fahrzeugstatus, Profil, Tankvorgang } from "./types.ts"; diff --git a/companion-app/src/main.tsx b/companion-app/src/main.tsx index 749d4e9..bcbd8dc 100644 --- a/companion-app/src/main.tsx +++ b/companion-app/src/main.tsx @@ -4,8 +4,13 @@ import { createRoot } from "react-dom/client" import "@audi-dash/ui/styles.css" import "./stile/grundlage.css" import "./stile/audi-schrift.css" +import { nativeAblageEinrichten } from "./api" import { App } from "./App" +// In der nativen Hülle den Token in den Schlüsselbund legen statt in +// localStorage. Ohne Hülle passiert hier nichts. +nativeAblageEinrichten() + const wurzel = document.getElementById("wurzel") if (!wurzel) throw new Error("Wurzelelement #wurzel fehlt in index.html") diff --git a/homeassistant/FMM003_MAPPING.md b/homeassistant/FMM003_MAPPING.md new file mode 100644 index 0000000..5a70b6b --- /dev/null +++ b/homeassistant/FMM003_MAPPING.md @@ -0,0 +1,136 @@ +# FMM003 → Home Assistant: Zuordnungstabelle + +**Gerüst, noch nicht ausgefüllt.** Gehört zu Phase 13 des +`../UMSETZUNGSPLAN.md`; die Architektur dahinter steht in +`../COMPANION_APP_ARCHITECTURE.md` §2b. + +> **Grundregel: nicht raten.** Wie die CAN/FMS-Werte im Codec JSON heißen, ist +> unbekannt und je nach Firmware verschieden. Die Tabelle unten wird aus einer +> **echten mitgeschnittenen Nachricht** gefüllt, nicht aus der Dokumentation +> abgeleitet. Bis dahin bleiben die Felder leer. + +## Datenweg + +``` +FMM003 (CAN/FMS) ──MQTT über TLS──▶ Mosquitto-Add-on ──▶ MQTT-Integration ──▶ Entitäten +``` + +Traccar entfällt: Das Gerät liefert bereits JSON, es gibt nichts zu dekodieren. + +## Schritt 1 — Gerät einstellen + +Im Teltonika Configurator: + +| Ort | Einstellung | +|---|---| +| System → System Settings → Data Protocol | **Codec JSON** | +| GPRS → Server Settings | Broker-IP und Port **8883** | +| GPRS → Server Settings | Benutzername und Passwort des Brokers | +| Security | Wurzelzertifikat, Gerätezertifikat, privater Schlüssel | + +**Firmware-Version hier eintragen:** `________________` + +Codec JSON ist nicht in jeder Firmware enthalten (bei mehreren Modellen erst ab +03.28.00). Ohne hochgeladene Zertifikate verweigert das Gerät die +MQTT-Verbindung — das ist kein Fehler, sondern Absicht. + +## Schritt 2 — Nachricht mitschneiden + +```bash +mosquitto_sub -h -p 8883 --cafile ca.crt \ + -u -P -t '#' -v | tee mitschnitt.txt +``` + +Dabei nacheinander: Zündung an, kurz fahren, anhalten, Zündung aus. Danach den +Mitschnitt sichten und die Tabelle unten füllen. + +**Nicht ins Repository legen** — der Mitschnitt enthält echte Positionsdaten. + +## Schritt 3 — Zuordnungstabelle + +Aus dem Mitschnitt zu füllen. Die Spalte „Entität" nennt den Namen, unter dem +das Backend den Wert erwartet. + +| JSON-Feld | Bedeutung | Einheit | Entität in HA | Gefunden am | +|---|---|---|---|---| +| | Zündung an/aus | 0/1 | `binary_sensor.audi_zuendung` | | +| | Geschwindigkeit | km/h | `sensor.audi_geschwindigkeit` | | +| | Position Breite | Grad | `device_tracker.audi_position` | | +| | Position Länge | Grad | `device_tracker.audi_position` | | +| | Kilometerstand | km | `sensor.audi_kilometerstand` | | +| | Tankfüllstand | % | `sensor.audi_tankfuellstand` | | +| | Drehzahl | min⁻¹ | `sensor.audi_drehzahl` | | +| | Kühlmitteltemperatur | °C | `sensor.audi_kuehlmittel` | | +| | Bordspannung | V | `sensor.audi_bordspannung` | | +| | Zeitstempel | ISO/Epoch | — (Attribut) | | + +Teltonika nennt CAN-Werte oft nur über eine AVL-Kennung (etwa `"12"` für +Kraftstoffstand). Deshalb hier immer **beides** notieren: die Kennung aus der +Nachricht und ihre Bedeutung aus der AVL-Liste des Geräts. + +## Schritt 4 — MQTT-Integration + +Aus der ausgefüllten Tabelle entstehen die Entitäten. Gerüst für +`configuration.yaml`, die Feldnamen in den Vorlagen sind erst danach einsetzbar: + +```yaml +mqtt: + binary_sensor: + - name: "Audi Zündung" + state_topic: "teltonika//data" + value_template: "{{ 'ON' if value_json. | int == 1 else 'OFF' }}" + device_class: power + + sensor: + - name: "Audi Geschwindigkeit" + state_topic: "teltonika//data" + value_template: "{{ value_json. }}" + unit_of_measurement: "km/h" + state_class: measurement + + device_tracker: + - name: "Audi Position" + state_topic: "teltonika//data" + json_attributes_topic: "teltonika//data" + # latitude/longitude erwartet HA als Attribute +``` + +## Schritt 5 — Fahrterkennung umstellen + +Neues `pyscript/fahrterkennung_fmm003.py` nach dem Muster von +`pyscript/fahrterkennung.py`, mit zwei Unterschieden: + +1. Der Auslöser ist die **Zündungs-Entität**, nicht der WLAN-Sensor. +2. Der Startzeitpunkt darf nicht mehr nur im Arbeitsspeicher liegen — beim + FMM003 gibt es keinen Grund mehr für diese Einschränkung, weil der Zustand + jederzeit vom Gerät neu gelesen werden kann. + +**Unverändert übernehmen:** die Pausentoleranz (`fahrten_pausenzeit_min` aus dem +Profil, Standard 15 Minuten) über `task.unique()` und `task.sleep()`. Das ist +eine bewusste Produktentscheidung, dreimal durchdacht: Springt der Motor +innerhalb der Toleranz wieder an, ist es **eine** Fahrt, nicht zwei. Die Zündung +fällt bei Start-Stopp nicht ab, ein zusätzlicher Filter dafür ist also +unnötig. + +Vorgehen: erst parallel laufen lassen und in eine Testdatei schreiben, mit der +bisherigen Erkennung vergleichen, dann scharf schalten. Danach +`WLAN_SENSOR` aus `pyscript/modules/einstellungen.py` entfernen. + +## Schritt 6 — Live-Ansicht einschalten + +In `companion-app/src/funktionen.ts` den Schalter `LIVE_VERFUEGBAR` auf `true` +setzen und in `companion-app/src/screens/LiveFahrt.tsx` die Feldnamen in +`liveZustandLesen()` an die tatsächlichen Entitäten anpassen. + +Damit werden auch die Fahrtdetails erstmals echte Routen zeigen, statt nur +Start- und Zielpunkt. + +## Noch zu entscheiden + +- **Erreichbarkeit des Brokers für das Fahrzeug:** Portfreigabe 8883 direkt auf + Mosquitto gegen VPS-Broker mit Mosquitto-Bridge über Tailscale. Ersteres ist + einfacher, aber eine echte Freigabe; letzteres öffnet zuhause nichts, kostet + aber einen zusätzlichen Server. +- **Datenvolumen:** Codec JSON braucht deutlich mehr Bytes als das binäre + Codec8E. Bei einem privat genutzten Fahrzeug vernachlässigbar, bei knappem + Mobilfunktarif im Blick behalten. diff --git a/homeassistant/REVERSE_PROXY.md b/homeassistant/REVERSE_PROXY.md new file mode 100644 index 0000000..3a32b94 --- /dev/null +++ b/homeassistant/REVERSE_PROXY.md @@ -0,0 +1,126 @@ +# Reverse-Proxy: Pfad-Freigabeliste für DataMetric360 + +**Vorbereitung, noch nicht in Betrieb.** Gehört zu Phase 12 des +`../UMSETZUNGSPLAN.md`; die Entscheidung dahinter steht in +`../COMPANION_APP_ARCHITECTURE.md` §4. + +Ziel: Die App erreicht Home Assistant von unterwegs, **ohne dass Home Assistant +selbst im Internet steht**. Dazu ein Cloudflare-Tunnel (baut nur nach außen auf, +kein Port am Router) auf einen Reverse Proxy, der ausschließlich die unten +aufgeführten Pfade durchlässt. + +## Was die App wirklich braucht + +Aus dem tatsächlichen Code der Datenschicht (`companion-app/src/api/`) +abgeleitet, nicht geschätzt: + +| Methode | Pfad | Wofür | +|---|---|---| +| `GET` | `/api/` | Verbindungsprüfung bei der Ersteinrichtung | +| `GET` | `/api/states/pyscript.audi_dashboard_*` | Profil, Fahrten, Tankvorgänge, Status, Batterieverlauf, Belegergebnis, Updatestatus | +| `GET` | `/api/states/pyscript.reifen_*` | Reifensatz-Kilometerstände | +| `POST` | `/api/services/pyscript/audi_dashboard_*` | alle schreibenden Vorgänge | +| `GET` (Upgrade) | `/api/websocket` | Live-Aktualisierung | + +Alles andere wird geblockt, insbesondere `/auth/*`, `/lovelace*`, `/config*`, +`/api/config`, `/api/history*`, `/developer-tools*` und die Oberfläche selbst. + +## Ehrliche Einschränkung + +**Der WebSocket lässt sich nicht pfadgenau beschneiden.** Nach `auth_ok` kann +über `/api/websocket` grundsätzlich jeder Zustand gelesen werden, nicht nur die +`pyscript.*`-Entitäten. Die Freigabeliste ist an dieser Stelle also grobkörniger +als beim REST-Zugriff. + +Was bleibt: Ohne gültigen Token kommt gar keine Verbindung zustande, und ein +verlorenes Gerät wird durch Zurückziehen genau seines Tokens ausgesperrt. Das +ist die im Architekturdokument bewusst akzeptierte Abwägung — sie sollte nur +nicht in Vergessenheit geraten. + +Wer sie nicht eingehen will, hat eine Alternative: den WebSocket weglassen und +die App auf regelmäßiges Abfragen umstellen. Kostet Akku und Datenvolumen, +verkleinert die Angriffsfläche aber auf die exakt aufgeführten REST-Pfade. + +## Nginx Proxy Manager + +Im Add-on unter *Hosts → Proxy Hosts → Edit → Advanced* eintragen. Ziel ist der +interne Name der Home-Assistant-Instanz (im Supervisor-Netz `homeassistant:8123`). + +```nginx +# Reihenfolge zählt: die erlaubenden Blöcke stehen vor dem pauschalen Verbot. + +location = /api/ { + proxy_pass http://homeassistant:8123; + include conf.d/include/proxy.conf; +} + +location ~ ^/api/states/pyscript\.(audi_dashboard_[a-z_]+|reifen_[a-z_]+)$ { + limit_except GET { deny all; } + proxy_pass http://homeassistant:8123; + include conf.d/include/proxy.conf; +} + +location ~ ^/api/services/pyscript/audi_dashboard_[a-z_]+$ { + limit_except POST { deny all; } + proxy_pass http://homeassistant:8123; + include conf.d/include/proxy.conf; +} + +location = /api/websocket { + proxy_pass http://homeassistant:8123; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; + include conf.d/include/proxy.conf; +} + +# Die Fotos der Fahrzeuge, die die App anzeigt. Nur Lesen, nur Bilder. +location ~ ^/local/bilder/[a-z0-9_-]+\.(webp|png|jpg|svg)$ { + limit_except GET { deny all; } + proxy_pass http://homeassistant:8123; + include conf.d/include/proxy.conf; +} + +# Alles Übrige: nicht durchlassen. +location / { + return 404; +} +``` + +Wird die App selbst unter derselben Adresse ausgeliefert, braucht sie einen +eigenen Block auf ihr Verzeichnis — sauberer ist ein getrennter Hostname +(`datametric360.app` für die App, `api.datametric360.app` für die +Schnittstelle), dann bleibt diese Liste unverändert. + +## Nach der Einrichtung prüfen + +Von einem Netz ohne VPN, etwa über Mobilfunk: + +```bash +API=https://api.datametric360.app +T= + +# muss gehen +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" "$API/api/" +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \ + "$API/api/states/pyscript.audi_dashboard_profil" + +# muss 404 oder 403 liefern +curl -s -o /dev/null -w '%{http_code}\n' "$API/auth/authorize" +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" "$API/api/config" +curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $T" \ + "$API/api/states/sensor.audi_rs_4_avant_mileage" + +# ohne Token: 401, nicht 200 +curl -s -o /dev/null -w '%{http_code}\n' "$API/api/states/pyscript.audi_dashboard_profil" +``` + +Zusätzlich: Port 8123 darf von außen **gar nicht** antworten. + +## Offen bis zur Einrichtung + +- Nameserver von `datametric360.app` auf Cloudflare umstellen („Full setup") +- Entscheidung Nginx Proxy Manager gegen Traefik (die Liste oben ist für NPM + geschrieben und für Traefik sinngemäß zu übertragen) +- Endgültige Hostnamen festlegen und hier eintragen diff --git a/homeassistant/www/dm360-qr.html b/homeassistant/www/dm360-qr.html new file mode 100644 index 0000000..d1189b6 --- /dev/null +++ b/homeassistant/www/dm360-qr.html @@ -0,0 +1,115 @@ + + + + + + DataMetric360 — Gerät verbinden + + + + + +
+

Gerät verbinden

+

+ Trag die von außen erreichbare Adresse und einen langlebigen Zugriffstoken ein. + Der Code entsteht hier auf dem Gerät; nichts davon wird irgendwohin gesendet. +

+ + + + + + + + + +
+ +
+ + + + diff --git a/homeassistant/www/dm360-qrcode-lib.js b/homeassistant/www/dm360-qrcode-lib.js new file mode 100644 index 0000000..df13f82 --- /dev/null +++ b/homeassistant/www/dm360-qrcode-lib.js @@ -0,0 +1,2297 @@ +//--------------------------------------------------------------------- +// +// QR Code Generator for JavaScript +// +// Copyright (c) 2009 Kazuhiko Arase +// +// URL: http://www.d-project.com/ +// +// Licensed under the MIT license: +// http://www.opensource.org/licenses/mit-license.php +// +// The word 'QR Code' is registered trademark of +// DENSO WAVE INCORPORATED +// http://www.denso-wave.com/qrcode/faqpatent-e.html +// +//--------------------------------------------------------------------- + +var qrcode = function() { + + //--------------------------------------------------------------------- + // qrcode + //--------------------------------------------------------------------- + + /** + * qrcode + * @param typeNumber 1 to 40 + * @param errorCorrectionLevel 'L','M','Q','H' + */ + var qrcode = function(typeNumber, errorCorrectionLevel) { + + var PAD0 = 0xEC; + var PAD1 = 0x11; + + var _typeNumber = typeNumber; + var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; + var _modules = null; + var _moduleCount = 0; + var _dataCache = null; + var _dataList = []; + + var _this = {}; + + var makeImpl = function(test, maskPattern) { + + _moduleCount = _typeNumber * 4 + 17; + _modules = function(moduleCount) { + var modules = new Array(moduleCount); + for (var row = 0; row < moduleCount; row += 1) { + modules[row] = new Array(moduleCount); + for (var col = 0; col < moduleCount; col += 1) { + modules[row][col] = null; + } + } + return modules; + }(_moduleCount); + + setupPositionProbePattern(0, 0); + setupPositionProbePattern(_moduleCount - 7, 0); + setupPositionProbePattern(0, _moduleCount - 7); + setupPositionAdjustPattern(); + setupTimingPattern(); + setupTypeInfo(test, maskPattern); + + if (_typeNumber >= 7) { + setupTypeNumber(test); + } + + if (_dataCache == null) { + _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); + } + + mapData(_dataCache, maskPattern); + }; + + var setupPositionProbePattern = function(row, col) { + + for (var r = -1; r <= 7; r += 1) { + + if (row + r <= -1 || _moduleCount <= row + r) continue; + + for (var c = -1; c <= 7; c += 1) { + + if (col + c <= -1 || _moduleCount <= col + c) continue; + + if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) + || (0 <= c && c <= 6 && (r == 0 || r == 6) ) + || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + }; + + var getBestMaskPattern = function() { + + var minLostPoint = 0; + var pattern = 0; + + for (var i = 0; i < 8; i += 1) { + + makeImpl(true, i); + + var lostPoint = QRUtil.getLostPoint(_this); + + if (i == 0 || minLostPoint > lostPoint) { + minLostPoint = lostPoint; + pattern = i; + } + } + + return pattern; + }; + + var setupTimingPattern = function() { + + for (var r = 8; r < _moduleCount - 8; r += 1) { + if (_modules[r][6] != null) { + continue; + } + _modules[r][6] = (r % 2 == 0); + } + + for (var c = 8; c < _moduleCount - 8; c += 1) { + if (_modules[6][c] != null) { + continue; + } + _modules[6][c] = (c % 2 == 0); + } + }; + + var setupPositionAdjustPattern = function() { + + var pos = QRUtil.getPatternPosition(_typeNumber); + + for (var i = 0; i < pos.length; i += 1) { + + for (var j = 0; j < pos.length; j += 1) { + + var row = pos[i]; + var col = pos[j]; + + if (_modules[row][col] != null) { + continue; + } + + for (var r = -2; r <= 2; r += 1) { + + for (var c = -2; c <= 2; c += 1) { + + if (r == -2 || r == 2 || c == -2 || c == 2 + || (r == 0 && c == 0) ) { + _modules[row + r][col + c] = true; + } else { + _modules[row + r][col + c] = false; + } + } + } + } + } + }; + + var setupTypeNumber = function(test) { + + var bits = QRUtil.getBCHTypeNumber(_typeNumber); + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; + } + + for (var i = 0; i < 18; i += 1) { + var mod = (!test && ( (bits >> i) & 1) == 1); + _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; + } + }; + + var setupTypeInfo = function(test, maskPattern) { + + var data = (_errorCorrectionLevel << 3) | maskPattern; + var bits = QRUtil.getBCHTypeInfo(data); + + // vertical + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 6) { + _modules[i][8] = mod; + } else if (i < 8) { + _modules[i + 1][8] = mod; + } else { + _modules[_moduleCount - 15 + i][8] = mod; + } + } + + // horizontal + for (var i = 0; i < 15; i += 1) { + + var mod = (!test && ( (bits >> i) & 1) == 1); + + if (i < 8) { + _modules[8][_moduleCount - i - 1] = mod; + } else if (i < 9) { + _modules[8][15 - i - 1 + 1] = mod; + } else { + _modules[8][15 - i - 1] = mod; + } + } + + // fixed module + _modules[_moduleCount - 8][8] = (!test); + }; + + var mapData = function(data, maskPattern) { + + var inc = -1; + var row = _moduleCount - 1; + var bitIndex = 7; + var byteIndex = 0; + var maskFunc = QRUtil.getMaskFunction(maskPattern); + + for (var col = _moduleCount - 1; col > 0; col -= 2) { + + if (col == 6) col -= 1; + + while (true) { + + for (var c = 0; c < 2; c += 1) { + + if (_modules[row][col - c] == null) { + + var dark = false; + + if (byteIndex < data.length) { + dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); + } + + var mask = maskFunc(row, col - c); + + if (mask) { + dark = !dark; + } + + _modules[row][col - c] = dark; + bitIndex -= 1; + + if (bitIndex == -1) { + byteIndex += 1; + bitIndex = 7; + } + } + } + + row += inc; + + if (row < 0 || _moduleCount <= row) { + row -= inc; + inc = -inc; + break; + } + } + } + }; + + var createBytes = function(buffer, rsBlocks) { + + var offset = 0; + + var maxDcCount = 0; + var maxEcCount = 0; + + var dcdata = new Array(rsBlocks.length); + var ecdata = new Array(rsBlocks.length); + + for (var r = 0; r < rsBlocks.length; r += 1) { + + var dcCount = rsBlocks[r].dataCount; + var ecCount = rsBlocks[r].totalCount - dcCount; + + maxDcCount = Math.max(maxDcCount, dcCount); + maxEcCount = Math.max(maxEcCount, ecCount); + + dcdata[r] = new Array(dcCount); + + for (var i = 0; i < dcdata[r].length; i += 1) { + dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; + } + offset += dcCount; + + var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); + var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); + + var modPoly = rawPoly.mod(rsPoly); + ecdata[r] = new Array(rsPoly.getLength() - 1); + for (var i = 0; i < ecdata[r].length; i += 1) { + var modIndex = i + modPoly.getLength() - ecdata[r].length; + ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; + } + } + + var totalCodeCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalCodeCount += rsBlocks[i].totalCount; + } + + var data = new Array(totalCodeCount); + var index = 0; + + for (var i = 0; i < maxDcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < dcdata[r].length) { + data[index] = dcdata[r][i]; + index += 1; + } + } + } + + for (var i = 0; i < maxEcCount; i += 1) { + for (var r = 0; r < rsBlocks.length; r += 1) { + if (i < ecdata[r].length) { + data[index] = ecdata[r][i]; + index += 1; + } + } + } + + return data; + }; + + var createData = function(typeNumber, errorCorrectionLevel, dataList) { + + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); + + var buffer = qrBitBuffer(); + + for (var i = 0; i < dataList.length; i += 1) { + var data = dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + // calc num max data. + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i += 1) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() > totalDataCount * 8) { + throw 'code length overflow. (' + + buffer.getLengthInBits() + + '>' + + totalDataCount * 8 + + ')'; + } + + // end code + if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { + buffer.put(0, 4); + } + + // padding + while (buffer.getLengthInBits() % 8 != 0) { + buffer.putBit(false); + } + + // padding + while (true) { + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD0, 8); + + if (buffer.getLengthInBits() >= totalDataCount * 8) { + break; + } + buffer.put(PAD1, 8); + } + + return createBytes(buffer, rsBlocks); + }; + + _this.addData = function(data, mode) { + + mode = mode || 'Byte'; + + var newData = null; + + switch(mode) { + case 'Numeric' : + newData = qrNumber(data); + break; + case 'Alphanumeric' : + newData = qrAlphaNum(data); + break; + case 'Byte' : + newData = qr8BitByte(data); + break; + case 'Kanji' : + newData = qrKanji(data); + break; + default : + throw 'mode:' + mode; + } + + _dataList.push(newData); + _dataCache = null; + }; + + _this.isDark = function(row, col) { + if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { + throw row + ',' + col; + } + return _modules[row][col]; + }; + + _this.getModuleCount = function() { + return _moduleCount; + }; + + _this.make = function() { + if (_typeNumber < 1) { + var typeNumber = 1; + + for (; typeNumber < 40; typeNumber++) { + var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); + var buffer = qrBitBuffer(); + + for (var i = 0; i < _dataList.length; i++) { + var data = _dataList[i]; + buffer.put(data.getMode(), 4); + buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); + data.write(buffer); + } + + var totalDataCount = 0; + for (var i = 0; i < rsBlocks.length; i++) { + totalDataCount += rsBlocks[i].dataCount; + } + + if (buffer.getLengthInBits() <= totalDataCount * 8) { + break; + } + } + + _typeNumber = typeNumber; + } + + makeImpl(false, getBestMaskPattern() ); + }; + + _this.createTableTag = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var qrHtml = ''; + + qrHtml += ''; + qrHtml += ''; + + for (var r = 0; r < _this.getModuleCount(); r += 1) { + + qrHtml += ''; + + for (var c = 0; c < _this.getModuleCount(); c += 1) { + qrHtml += ''; + } + + qrHtml += ''; + qrHtml += '
'; + } + + qrHtml += '
'; + + return qrHtml; + }; + + _this.createSvgTag = function(cellSize, margin, alt, title) { + + var opts = {}; + if (typeof arguments[0] == 'object') { + // Called by options. + opts = arguments[0]; + // overwrite cellSize and margin. + cellSize = opts.cellSize; + margin = opts.margin; + alt = opts.alt; + title = opts.title; + } + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + // Compose alt property surrogate + alt = (typeof alt === 'string') ? {text: alt} : alt || {}; + alt.text = alt.text || null; + alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; + + // Compose title property surrogate + title = (typeof title === 'string') ? {text: title} : title || {}; + title.text = title.text || null; + title.id = (title.text) ? title.id || 'qrcode-title' : null; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var c, mc, r, mr, qrSvg='', rect; + + rect = 'l' + cellSize + ',0 0,' + cellSize + + ' -' + cellSize + ',0 0,-' + cellSize + 'z '; + + qrSvg += '' + + escapeXml(title.text) + '' : ''; + qrSvg += (alt.text) ? '' + + escapeXml(alt.text) + '' : ''; + qrSvg += ''; + qrSvg += ''; + qrSvg += ''; + + return qrSvg; + }; + + _this.createDataURL = function(cellSize, margin) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + return createDataURL(size, size, function(x, y) { + if (min <= x && x < max && min <= y && y < max) { + var c = Math.floor( (x - min) / cellSize); + var r = Math.floor( (y - min) / cellSize); + return _this.isDark(r, c)? 0 : 1; + } else { + return 1; + } + } ); + }; + + _this.createImgTag = function(cellSize, margin, alt) { + + cellSize = cellSize || 2; + margin = (typeof margin == 'undefined')? cellSize * 4 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + + var img = ''; + img += '': escaped += '>'; break; + case '&': escaped += '&'; break; + case '"': escaped += '"'; break; + default : escaped += c; break; + } + } + return escaped; + }; + + var _createHalfASCII = function(margin) { + var cellSize = 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r1, r2, p; + + var blocks = { + '██': '█', + '█ ': '▀', + ' █': '▄', + ' ': ' ' + }; + + var blocksLastLineNoMargin = { + '██': '▀', + '█ ': '▀', + ' █': ' ', + ' ': ' ' + }; + + var ascii = ''; + for (y = 0; y < size; y += 2) { + r1 = Math.floor((y - min) / cellSize); + r2 = Math.floor((y + 1 - min) / cellSize); + for (x = 0; x < size; x += 1) { + p = '█'; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { + p = ' '; + } + + if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { + p += ' '; + } + else { + p += '█'; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; + } + + ascii += '\n'; + } + + if (size % 2 && margin > 0) { + return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.createASCII = function(cellSize, margin) { + cellSize = cellSize || 1; + + if (cellSize < 2) { + return _createHalfASCII(margin); + } + + cellSize -= 1; + margin = (typeof margin == 'undefined')? cellSize * 2 : margin; + + var size = _this.getModuleCount() * cellSize + margin * 2; + var min = margin; + var max = size - margin; + + var y, x, r, p; + + var white = Array(cellSize+1).join('██'); + var black = Array(cellSize+1).join(' '); + + var ascii = ''; + var line = ''; + for (y = 0; y < size; y += 1) { + r = Math.floor( (y - min) / cellSize); + line = ''; + for (x = 0; x < size; x += 1) { + p = 1; + + if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { + p = 0; + } + + // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. + line += p ? white : black; + } + + for (r = 0; r < cellSize; r += 1) { + ascii += line + '\n'; + } + } + + return ascii.substring(0, ascii.length-1); + }; + + _this.renderTo2dContext = function(context, cellSize) { + cellSize = cellSize || 2; + var length = _this.getModuleCount(); + for (var row = 0; row < length; row++) { + for (var col = 0; col < length; col++) { + context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; + context.fillRect(col * cellSize, row * cellSize, cellSize, cellSize); + } + } + } + + return _this; + }; + + //--------------------------------------------------------------------- + // qrcode.stringToBytes + //--------------------------------------------------------------------- + + qrcode.stringToBytesFuncs = { + 'default' : function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + bytes.push(c & 0xff); + } + return bytes; + } + }; + + qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; + + //--------------------------------------------------------------------- + // qrcode.createStringToBytes + //--------------------------------------------------------------------- + + /** + * @param unicodeData base64 string of byte array. + * [16bit Unicode],[16bit Bytes], ... + * @param numChars + */ + qrcode.createStringToBytes = function(unicodeData, numChars) { + + // create conversion map. + + var unicodeMap = function() { + + var bin = base64DecodeInputStream(unicodeData); + var read = function() { + var b = bin.read(); + if (b == -1) throw 'eof'; + return b; + }; + + var count = 0; + var unicodeMap = {}; + while (true) { + var b0 = bin.read(); + if (b0 == -1) break; + var b1 = read(); + var b2 = read(); + var b3 = read(); + var k = String.fromCharCode( (b0 << 8) | b1); + var v = (b2 << 8) | b3; + unicodeMap[k] = v; + count += 1; + } + if (count != numChars) { + throw count + ' != ' + numChars; + } + + return unicodeMap; + }(); + + var unknownChar = '?'.charCodeAt(0); + + return function(s) { + var bytes = []; + for (var i = 0; i < s.length; i += 1) { + var c = s.charCodeAt(i); + if (c < 128) { + bytes.push(c); + } else { + var b = unicodeMap[s.charAt(i)]; + if (typeof b == 'number') { + if ( (b & 0xff) == b) { + // 1byte + bytes.push(b); + } else { + // 2bytes + bytes.push(b >>> 8); + bytes.push(b & 0xff); + } + } else { + bytes.push(unknownChar); + } + } + } + return bytes; + }; + }; + + //--------------------------------------------------------------------- + // QRMode + //--------------------------------------------------------------------- + + var QRMode = { + MODE_NUMBER : 1 << 0, + MODE_ALPHA_NUM : 1 << 1, + MODE_8BIT_BYTE : 1 << 2, + MODE_KANJI : 1 << 3 + }; + + //--------------------------------------------------------------------- + // QRErrorCorrectionLevel + //--------------------------------------------------------------------- + + var QRErrorCorrectionLevel = { + L : 1, + M : 0, + Q : 3, + H : 2 + }; + + //--------------------------------------------------------------------- + // QRMaskPattern + //--------------------------------------------------------------------- + + var QRMaskPattern = { + PATTERN000 : 0, + PATTERN001 : 1, + PATTERN010 : 2, + PATTERN011 : 3, + PATTERN100 : 4, + PATTERN101 : 5, + PATTERN110 : 6, + PATTERN111 : 7 + }; + + //--------------------------------------------------------------------- + // QRUtil + //--------------------------------------------------------------------- + + var QRUtil = function() { + + var PATTERN_POSITION_TABLE = [ + [], + [6, 18], + [6, 22], + [6, 26], + [6, 30], + [6, 34], + [6, 22, 38], + [6, 24, 42], + [6, 26, 46], + [6, 28, 50], + [6, 30, 54], + [6, 32, 58], + [6, 34, 62], + [6, 26, 46, 66], + [6, 26, 48, 70], + [6, 26, 50, 74], + [6, 30, 54, 78], + [6, 30, 56, 82], + [6, 30, 58, 86], + [6, 34, 62, 90], + [6, 28, 50, 72, 94], + [6, 26, 50, 74, 98], + [6, 30, 54, 78, 102], + [6, 28, 54, 80, 106], + [6, 32, 58, 84, 110], + [6, 30, 58, 86, 114], + [6, 34, 62, 90, 118], + [6, 26, 50, 74, 98, 122], + [6, 30, 54, 78, 102, 126], + [6, 26, 52, 78, 104, 130], + [6, 30, 56, 82, 108, 134], + [6, 34, 60, 86, 112, 138], + [6, 30, 58, 86, 114, 142], + [6, 34, 62, 90, 118, 146], + [6, 30, 54, 78, 102, 126, 150], + [6, 24, 50, 76, 102, 128, 154], + [6, 28, 54, 80, 106, 132, 158], + [6, 32, 58, 84, 110, 136, 162], + [6, 26, 54, 82, 110, 138, 166], + [6, 30, 58, 86, 114, 142, 170] + ]; + var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); + var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); + var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); + + var _this = {}; + + var getBCHDigit = function(data) { + var digit = 0; + while (data != 0) { + digit += 1; + data >>>= 1; + } + return digit; + }; + + _this.getBCHTypeInfo = function(data) { + var d = data << 10; + while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { + d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); + } + return ( (data << 10) | d) ^ G15_MASK; + }; + + _this.getBCHTypeNumber = function(data) { + var d = data << 12; + while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { + d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); + } + return (data << 12) | d; + }; + + _this.getPatternPosition = function(typeNumber) { + return PATTERN_POSITION_TABLE[typeNumber - 1]; + }; + + _this.getMaskFunction = function(maskPattern) { + + switch (maskPattern) { + + case QRMaskPattern.PATTERN000 : + return function(i, j) { return (i + j) % 2 == 0; }; + case QRMaskPattern.PATTERN001 : + return function(i, j) { return i % 2 == 0; }; + case QRMaskPattern.PATTERN010 : + return function(i, j) { return j % 3 == 0; }; + case QRMaskPattern.PATTERN011 : + return function(i, j) { return (i + j) % 3 == 0; }; + case QRMaskPattern.PATTERN100 : + return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; + case QRMaskPattern.PATTERN101 : + return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; + case QRMaskPattern.PATTERN110 : + return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; + case QRMaskPattern.PATTERN111 : + return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; + + default : + throw 'bad maskPattern:' + maskPattern; + } + }; + + _this.getErrorCorrectPolynomial = function(errorCorrectLength) { + var a = qrPolynomial([1], 0); + for (var i = 0; i < errorCorrectLength; i += 1) { + a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); + } + return a; + }; + + _this.getLengthInBits = function(mode, type) { + + if (1 <= type && type < 10) { + + // 1 - 9 + + switch(mode) { + case QRMode.MODE_NUMBER : return 10; + case QRMode.MODE_ALPHA_NUM : return 9; + case QRMode.MODE_8BIT_BYTE : return 8; + case QRMode.MODE_KANJI : return 8; + default : + throw 'mode:' + mode; + } + + } else if (type < 27) { + + // 10 - 26 + + switch(mode) { + case QRMode.MODE_NUMBER : return 12; + case QRMode.MODE_ALPHA_NUM : return 11; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 10; + default : + throw 'mode:' + mode; + } + + } else if (type < 41) { + + // 27 - 40 + + switch(mode) { + case QRMode.MODE_NUMBER : return 14; + case QRMode.MODE_ALPHA_NUM : return 13; + case QRMode.MODE_8BIT_BYTE : return 16; + case QRMode.MODE_KANJI : return 12; + default : + throw 'mode:' + mode; + } + + } else { + throw 'type:' + type; + } + }; + + _this.getLostPoint = function(qrcode) { + + var moduleCount = qrcode.getModuleCount(); + + var lostPoint = 0; + + // LEVEL1 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount; col += 1) { + + var sameCount = 0; + var dark = qrcode.isDark(row, col); + + for (var r = -1; r <= 1; r += 1) { + + if (row + r < 0 || moduleCount <= row + r) { + continue; + } + + for (var c = -1; c <= 1; c += 1) { + + if (col + c < 0 || moduleCount <= col + c) { + continue; + } + + if (r == 0 && c == 0) { + continue; + } + + if (dark == qrcode.isDark(row + r, col + c) ) { + sameCount += 1; + } + } + } + + if (sameCount > 5) { + lostPoint += (3 + sameCount - 5); + } + } + }; + + // LEVEL2 + + for (var row = 0; row < moduleCount - 1; row += 1) { + for (var col = 0; col < moduleCount - 1; col += 1) { + var count = 0; + if (qrcode.isDark(row, col) ) count += 1; + if (qrcode.isDark(row + 1, col) ) count += 1; + if (qrcode.isDark(row, col + 1) ) count += 1; + if (qrcode.isDark(row + 1, col + 1) ) count += 1; + if (count == 0 || count == 4) { + lostPoint += 3; + } + } + } + + // LEVEL3 + + for (var row = 0; row < moduleCount; row += 1) { + for (var col = 0; col < moduleCount - 6; col += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row, col + 1) + && qrcode.isDark(row, col + 2) + && qrcode.isDark(row, col + 3) + && qrcode.isDark(row, col + 4) + && !qrcode.isDark(row, col + 5) + && qrcode.isDark(row, col + 6) ) { + lostPoint += 40; + } + } + } + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount - 6; row += 1) { + if (qrcode.isDark(row, col) + && !qrcode.isDark(row + 1, col) + && qrcode.isDark(row + 2, col) + && qrcode.isDark(row + 3, col) + && qrcode.isDark(row + 4, col) + && !qrcode.isDark(row + 5, col) + && qrcode.isDark(row + 6, col) ) { + lostPoint += 40; + } + } + } + + // LEVEL4 + + var darkCount = 0; + + for (var col = 0; col < moduleCount; col += 1) { + for (var row = 0; row < moduleCount; row += 1) { + if (qrcode.isDark(row, col) ) { + darkCount += 1; + } + } + } + + var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; + lostPoint += ratio * 10; + + return lostPoint; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // QRMath + //--------------------------------------------------------------------- + + var QRMath = function() { + + var EXP_TABLE = new Array(256); + var LOG_TABLE = new Array(256); + + // initialize tables + for (var i = 0; i < 8; i += 1) { + EXP_TABLE[i] = 1 << i; + } + for (var i = 8; i < 256; i += 1) { + EXP_TABLE[i] = EXP_TABLE[i - 4] + ^ EXP_TABLE[i - 5] + ^ EXP_TABLE[i - 6] + ^ EXP_TABLE[i - 8]; + } + for (var i = 0; i < 255; i += 1) { + LOG_TABLE[EXP_TABLE[i] ] = i; + } + + var _this = {}; + + _this.glog = function(n) { + + if (n < 1) { + throw 'glog(' + n + ')'; + } + + return LOG_TABLE[n]; + }; + + _this.gexp = function(n) { + + while (n < 0) { + n += 255; + } + + while (n >= 256) { + n -= 255; + } + + return EXP_TABLE[n]; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrPolynomial + //--------------------------------------------------------------------- + + function qrPolynomial(num, shift) { + + if (typeof num.length == 'undefined') { + throw num.length + '/' + shift; + } + + var _num = function() { + var offset = 0; + while (offset < num.length && num[offset] == 0) { + offset += 1; + } + var _num = new Array(num.length - offset + shift); + for (var i = 0; i < num.length - offset; i += 1) { + _num[i] = num[i + offset]; + } + return _num; + }(); + + var _this = {}; + + _this.getAt = function(index) { + return _num[index]; + }; + + _this.getLength = function() { + return _num.length; + }; + + _this.multiply = function(e) { + + var num = new Array(_this.getLength() + e.getLength() - 1); + + for (var i = 0; i < _this.getLength(); i += 1) { + for (var j = 0; j < e.getLength(); j += 1) { + num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); + } + } + + return qrPolynomial(num, 0); + }; + + _this.mod = function(e) { + + if (_this.getLength() - e.getLength() < 0) { + return _this; + } + + var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); + + var num = new Array(_this.getLength() ); + for (var i = 0; i < _this.getLength(); i += 1) { + num[i] = _this.getAt(i); + } + + for (var i = 0; i < e.getLength(); i += 1) { + num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); + } + + // recursive call + return qrPolynomial(num, 0).mod(e); + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // QRRSBlock + //--------------------------------------------------------------------- + + var QRRSBlock = function() { + + var RS_BLOCK_TABLE = [ + + // L + // M + // Q + // H + + // 1 + [1, 26, 19], + [1, 26, 16], + [1, 26, 13], + [1, 26, 9], + + // 2 + [1, 44, 34], + [1, 44, 28], + [1, 44, 22], + [1, 44, 16], + + // 3 + [1, 70, 55], + [1, 70, 44], + [2, 35, 17], + [2, 35, 13], + + // 4 + [1, 100, 80], + [2, 50, 32], + [2, 50, 24], + [4, 25, 9], + + // 5 + [1, 134, 108], + [2, 67, 43], + [2, 33, 15, 2, 34, 16], + [2, 33, 11, 2, 34, 12], + + // 6 + [2, 86, 68], + [4, 43, 27], + [4, 43, 19], + [4, 43, 15], + + // 7 + [2, 98, 78], + [4, 49, 31], + [2, 32, 14, 4, 33, 15], + [4, 39, 13, 1, 40, 14], + + // 8 + [2, 121, 97], + [2, 60, 38, 2, 61, 39], + [4, 40, 18, 2, 41, 19], + [4, 40, 14, 2, 41, 15], + + // 9 + [2, 146, 116], + [3, 58, 36, 2, 59, 37], + [4, 36, 16, 4, 37, 17], + [4, 36, 12, 4, 37, 13], + + // 10 + [2, 86, 68, 2, 87, 69], + [4, 69, 43, 1, 70, 44], + [6, 43, 19, 2, 44, 20], + [6, 43, 15, 2, 44, 16], + + // 11 + [4, 101, 81], + [1, 80, 50, 4, 81, 51], + [4, 50, 22, 4, 51, 23], + [3, 36, 12, 8, 37, 13], + + // 12 + [2, 116, 92, 2, 117, 93], + [6, 58, 36, 2, 59, 37], + [4, 46, 20, 6, 47, 21], + [7, 42, 14, 4, 43, 15], + + // 13 + [4, 133, 107], + [8, 59, 37, 1, 60, 38], + [8, 44, 20, 4, 45, 21], + [12, 33, 11, 4, 34, 12], + + // 14 + [3, 145, 115, 1, 146, 116], + [4, 64, 40, 5, 65, 41], + [11, 36, 16, 5, 37, 17], + [11, 36, 12, 5, 37, 13], + + // 15 + [5, 109, 87, 1, 110, 88], + [5, 65, 41, 5, 66, 42], + [5, 54, 24, 7, 55, 25], + [11, 36, 12, 7, 37, 13], + + // 16 + [5, 122, 98, 1, 123, 99], + [7, 73, 45, 3, 74, 46], + [15, 43, 19, 2, 44, 20], + [3, 45, 15, 13, 46, 16], + + // 17 + [1, 135, 107, 5, 136, 108], + [10, 74, 46, 1, 75, 47], + [1, 50, 22, 15, 51, 23], + [2, 42, 14, 17, 43, 15], + + // 18 + [5, 150, 120, 1, 151, 121], + [9, 69, 43, 4, 70, 44], + [17, 50, 22, 1, 51, 23], + [2, 42, 14, 19, 43, 15], + + // 19 + [3, 141, 113, 4, 142, 114], + [3, 70, 44, 11, 71, 45], + [17, 47, 21, 4, 48, 22], + [9, 39, 13, 16, 40, 14], + + // 20 + [3, 135, 107, 5, 136, 108], + [3, 67, 41, 13, 68, 42], + [15, 54, 24, 5, 55, 25], + [15, 43, 15, 10, 44, 16], + + // 21 + [4, 144, 116, 4, 145, 117], + [17, 68, 42], + [17, 50, 22, 6, 51, 23], + [19, 46, 16, 6, 47, 17], + + // 22 + [2, 139, 111, 7, 140, 112], + [17, 74, 46], + [7, 54, 24, 16, 55, 25], + [34, 37, 13], + + // 23 + [4, 151, 121, 5, 152, 122], + [4, 75, 47, 14, 76, 48], + [11, 54, 24, 14, 55, 25], + [16, 45, 15, 14, 46, 16], + + // 24 + [6, 147, 117, 4, 148, 118], + [6, 73, 45, 14, 74, 46], + [11, 54, 24, 16, 55, 25], + [30, 46, 16, 2, 47, 17], + + // 25 + [8, 132, 106, 4, 133, 107], + [8, 75, 47, 13, 76, 48], + [7, 54, 24, 22, 55, 25], + [22, 45, 15, 13, 46, 16], + + // 26 + [10, 142, 114, 2, 143, 115], + [19, 74, 46, 4, 75, 47], + [28, 50, 22, 6, 51, 23], + [33, 46, 16, 4, 47, 17], + + // 27 + [8, 152, 122, 4, 153, 123], + [22, 73, 45, 3, 74, 46], + [8, 53, 23, 26, 54, 24], + [12, 45, 15, 28, 46, 16], + + // 28 + [3, 147, 117, 10, 148, 118], + [3, 73, 45, 23, 74, 46], + [4, 54, 24, 31, 55, 25], + [11, 45, 15, 31, 46, 16], + + // 29 + [7, 146, 116, 7, 147, 117], + [21, 73, 45, 7, 74, 46], + [1, 53, 23, 37, 54, 24], + [19, 45, 15, 26, 46, 16], + + // 30 + [5, 145, 115, 10, 146, 116], + [19, 75, 47, 10, 76, 48], + [15, 54, 24, 25, 55, 25], + [23, 45, 15, 25, 46, 16], + + // 31 + [13, 145, 115, 3, 146, 116], + [2, 74, 46, 29, 75, 47], + [42, 54, 24, 1, 55, 25], + [23, 45, 15, 28, 46, 16], + + // 32 + [17, 145, 115], + [10, 74, 46, 23, 75, 47], + [10, 54, 24, 35, 55, 25], + [19, 45, 15, 35, 46, 16], + + // 33 + [17, 145, 115, 1, 146, 116], + [14, 74, 46, 21, 75, 47], + [29, 54, 24, 19, 55, 25], + [11, 45, 15, 46, 46, 16], + + // 34 + [13, 145, 115, 6, 146, 116], + [14, 74, 46, 23, 75, 47], + [44, 54, 24, 7, 55, 25], + [59, 46, 16, 1, 47, 17], + + // 35 + [12, 151, 121, 7, 152, 122], + [12, 75, 47, 26, 76, 48], + [39, 54, 24, 14, 55, 25], + [22, 45, 15, 41, 46, 16], + + // 36 + [6, 151, 121, 14, 152, 122], + [6, 75, 47, 34, 76, 48], + [46, 54, 24, 10, 55, 25], + [2, 45, 15, 64, 46, 16], + + // 37 + [17, 152, 122, 4, 153, 123], + [29, 74, 46, 14, 75, 47], + [49, 54, 24, 10, 55, 25], + [24, 45, 15, 46, 46, 16], + + // 38 + [4, 152, 122, 18, 153, 123], + [13, 74, 46, 32, 75, 47], + [48, 54, 24, 14, 55, 25], + [42, 45, 15, 32, 46, 16], + + // 39 + [20, 147, 117, 4, 148, 118], + [40, 75, 47, 7, 76, 48], + [43, 54, 24, 22, 55, 25], + [10, 45, 15, 67, 46, 16], + + // 40 + [19, 148, 118, 6, 149, 119], + [18, 75, 47, 31, 76, 48], + [34, 54, 24, 34, 55, 25], + [20, 45, 15, 61, 46, 16] + ]; + + var qrRSBlock = function(totalCount, dataCount) { + var _this = {}; + _this.totalCount = totalCount; + _this.dataCount = dataCount; + return _this; + }; + + var _this = {}; + + var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { + + switch(errorCorrectionLevel) { + case QRErrorCorrectionLevel.L : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; + case QRErrorCorrectionLevel.M : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; + case QRErrorCorrectionLevel.Q : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; + case QRErrorCorrectionLevel.H : + return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; + default : + return undefined; + } + }; + + _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { + + var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); + + if (typeof rsBlock == 'undefined') { + throw 'bad rs block @ typeNumber:' + typeNumber + + '/errorCorrectionLevel:' + errorCorrectionLevel; + } + + var length = rsBlock.length / 3; + + var list = []; + + for (var i = 0; i < length; i += 1) { + + var count = rsBlock[i * 3 + 0]; + var totalCount = rsBlock[i * 3 + 1]; + var dataCount = rsBlock[i * 3 + 2]; + + for (var j = 0; j < count; j += 1) { + list.push(qrRSBlock(totalCount, dataCount) ); + } + } + + return list; + }; + + return _this; + }(); + + //--------------------------------------------------------------------- + // qrBitBuffer + //--------------------------------------------------------------------- + + var qrBitBuffer = function() { + + var _buffer = []; + var _length = 0; + + var _this = {}; + + _this.getBuffer = function() { + return _buffer; + }; + + _this.getAt = function(index) { + var bufIndex = Math.floor(index / 8); + return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; + }; + + _this.put = function(num, length) { + for (var i = 0; i < length; i += 1) { + _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); + } + }; + + _this.getLengthInBits = function() { + return _length; + }; + + _this.putBit = function(bit) { + + var bufIndex = Math.floor(_length / 8); + if (_buffer.length <= bufIndex) { + _buffer.push(0); + } + + if (bit) { + _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); + } + + _length += 1; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrNumber + //--------------------------------------------------------------------- + + var qrNumber = function(data) { + + var _mode = QRMode.MODE_NUMBER; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var data = _data; + + var i = 0; + + while (i + 2 < data.length) { + buffer.put(strToNum(data.substring(i, i + 3) ), 10); + i += 3; + } + + if (i < data.length) { + if (data.length - i == 1) { + buffer.put(strToNum(data.substring(i, i + 1) ), 4); + } else if (data.length - i == 2) { + buffer.put(strToNum(data.substring(i, i + 2) ), 7); + } + } + }; + + var strToNum = function(s) { + var num = 0; + for (var i = 0; i < s.length; i += 1) { + num = num * 10 + chatToNum(s.charAt(i) ); + } + return num; + }; + + var chatToNum = function(c) { + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } + throw 'illegal char :' + c; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrAlphaNum + //--------------------------------------------------------------------- + + var qrAlphaNum = function(data) { + + var _mode = QRMode.MODE_ALPHA_NUM; + var _data = data; + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _data.length; + }; + + _this.write = function(buffer) { + + var s = _data; + + var i = 0; + + while (i + 1 < s.length) { + buffer.put( + getCode(s.charAt(i) ) * 45 + + getCode(s.charAt(i + 1) ), 11); + i += 2; + } + + if (i < s.length) { + buffer.put(getCode(s.charAt(i) ), 6); + } + }; + + var getCode = function(c) { + + if ('0' <= c && c <= '9') { + return c.charCodeAt(0) - '0'.charCodeAt(0); + } else if ('A' <= c && c <= 'Z') { + return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; + } else { + switch (c) { + case ' ' : return 36; + case '$' : return 37; + case '%' : return 38; + case '*' : return 39; + case '+' : return 40; + case '-' : return 41; + case '.' : return 42; + case '/' : return 43; + case ':' : return 44; + default : + throw 'illegal char :' + c; + } + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qr8BitByte + //--------------------------------------------------------------------- + + var qr8BitByte = function(data) { + + var _mode = QRMode.MODE_8BIT_BYTE; + var _data = data; + var _bytes = qrcode.stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return _bytes.length; + }; + + _this.write = function(buffer) { + for (var i = 0; i < _bytes.length; i += 1) { + buffer.put(_bytes[i], 8); + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // qrKanji + //--------------------------------------------------------------------- + + var qrKanji = function(data) { + + var _mode = QRMode.MODE_KANJI; + var _data = data; + + var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; + if (!stringToBytes) { + throw 'sjis not supported.'; + } + !function(c, code) { + // self test for sjis support. + var test = stringToBytes(c); + if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { + throw 'sjis not supported.'; + } + }('\u53cb', 0x9746); + + var _bytes = stringToBytes(data); + + var _this = {}; + + _this.getMode = function() { + return _mode; + }; + + _this.getLength = function(buffer) { + return ~~(_bytes.length / 2); + }; + + _this.write = function(buffer) { + + var data = _bytes; + + var i = 0; + + while (i + 1 < data.length) { + + var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); + + if (0x8140 <= c && c <= 0x9FFC) { + c -= 0x8140; + } else if (0xE040 <= c && c <= 0xEBBF) { + c -= 0xC140; + } else { + throw 'illegal char at ' + (i + 1) + '/' + c; + } + + c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); + + buffer.put(c, 13); + + i += 2; + } + + if (i < data.length) { + throw 'illegal char at ' + (i + 1); + } + }; + + return _this; + }; + + //===================================================================== + // GIF Support etc. + // + + //--------------------------------------------------------------------- + // byteArrayOutputStream + //--------------------------------------------------------------------- + + var byteArrayOutputStream = function() { + + var _bytes = []; + + var _this = {}; + + _this.writeByte = function(b) { + _bytes.push(b & 0xff); + }; + + _this.writeShort = function(i) { + _this.writeByte(i); + _this.writeByte(i >>> 8); + }; + + _this.writeBytes = function(b, off, len) { + off = off || 0; + len = len || b.length; + for (var i = 0; i < len; i += 1) { + _this.writeByte(b[i + off]); + } + }; + + _this.writeString = function(s) { + for (var i = 0; i < s.length; i += 1) { + _this.writeByte(s.charCodeAt(i) ); + } + }; + + _this.toByteArray = function() { + return _bytes; + }; + + _this.toString = function() { + var s = ''; + s += '['; + for (var i = 0; i < _bytes.length; i += 1) { + if (i > 0) { + s += ','; + } + s += _bytes[i]; + } + s += ']'; + return s; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64EncodeOutputStream + //--------------------------------------------------------------------- + + var base64EncodeOutputStream = function() { + + var _buffer = 0; + var _buflen = 0; + var _length = 0; + var _base64 = ''; + + var _this = {}; + + var writeEncoded = function(b) { + _base64 += String.fromCharCode(encode(b & 0x3f) ); + }; + + var encode = function(n) { + if (n < 0) { + // error. + } else if (n < 26) { + return 0x41 + n; + } else if (n < 52) { + return 0x61 + (n - 26); + } else if (n < 62) { + return 0x30 + (n - 52); + } else if (n == 62) { + return 0x2b; + } else if (n == 63) { + return 0x2f; + } + throw 'n:' + n; + }; + + _this.writeByte = function(n) { + + _buffer = (_buffer << 8) | (n & 0xff); + _buflen += 8; + _length += 1; + + while (_buflen >= 6) { + writeEncoded(_buffer >>> (_buflen - 6) ); + _buflen -= 6; + } + }; + + _this.flush = function() { + + if (_buflen > 0) { + writeEncoded(_buffer << (6 - _buflen) ); + _buffer = 0; + _buflen = 0; + } + + if (_length % 3 != 0) { + // padding + var padlen = 3 - _length % 3; + for (var i = 0; i < padlen; i += 1) { + _base64 += '='; + } + } + }; + + _this.toString = function() { + return _base64; + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // base64DecodeInputStream + //--------------------------------------------------------------------- + + var base64DecodeInputStream = function(str) { + + var _str = str; + var _pos = 0; + var _buffer = 0; + var _buflen = 0; + + var _this = {}; + + _this.read = function() { + + while (_buflen < 8) { + + if (_pos >= _str.length) { + if (_buflen == 0) { + return -1; + } + throw 'unexpected end of file./' + _buflen; + } + + var c = _str.charAt(_pos); + _pos += 1; + + if (c == '=') { + _buflen = 0; + return -1; + } else if (c.match(/^\s$/) ) { + // ignore if whitespace. + continue; + } + + _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); + _buflen += 6; + } + + var n = (_buffer >>> (_buflen - 8) ) & 0xff; + _buflen -= 8; + return n; + }; + + var decode = function(c) { + if (0x41 <= c && c <= 0x5a) { + return c - 0x41; + } else if (0x61 <= c && c <= 0x7a) { + return c - 0x61 + 26; + } else if (0x30 <= c && c <= 0x39) { + return c - 0x30 + 52; + } else if (c == 0x2b) { + return 62; + } else if (c == 0x2f) { + return 63; + } else { + throw 'c:' + c; + } + }; + + return _this; + }; + + //--------------------------------------------------------------------- + // gifImage (B/W) + //--------------------------------------------------------------------- + + var gifImage = function(width, height) { + + var _width = width; + var _height = height; + var _data = new Array(width * height); + + var _this = {}; + + _this.setPixel = function(x, y, pixel) { + _data[y * _width + x] = pixel; + }; + + _this.write = function(out) { + + //--------------------------------- + // GIF Signature + + out.writeString('GIF87a'); + + //--------------------------------- + // Screen Descriptor + + out.writeShort(_width); + out.writeShort(_height); + + out.writeByte(0x80); // 2bit + out.writeByte(0); + out.writeByte(0); + + //--------------------------------- + // Global Color Map + + // black + out.writeByte(0x00); + out.writeByte(0x00); + out.writeByte(0x00); + + // white + out.writeByte(0xff); + out.writeByte(0xff); + out.writeByte(0xff); + + //--------------------------------- + // Image Descriptor + + out.writeString(','); + out.writeShort(0); + out.writeShort(0); + out.writeShort(_width); + out.writeShort(_height); + out.writeByte(0); + + //--------------------------------- + // Local Color Map + + //--------------------------------- + // Raster Data + + var lzwMinCodeSize = 2; + var raster = getLZWRaster(lzwMinCodeSize); + + out.writeByte(lzwMinCodeSize); + + var offset = 0; + + while (raster.length - offset > 255) { + out.writeByte(255); + out.writeBytes(raster, offset, 255); + offset += 255; + } + + out.writeByte(raster.length - offset); + out.writeBytes(raster, offset, raster.length - offset); + out.writeByte(0x00); + + //--------------------------------- + // GIF Terminator + out.writeString(';'); + }; + + var bitOutputStream = function(out) { + + var _out = out; + var _bitLength = 0; + var _bitBuffer = 0; + + var _this = {}; + + _this.write = function(data, length) { + + if ( (data >>> length) != 0) { + throw 'length over'; + } + + while (_bitLength + length >= 8) { + _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); + length -= (8 - _bitLength); + data >>>= (8 - _bitLength); + _bitBuffer = 0; + _bitLength = 0; + } + + _bitBuffer = (data << _bitLength) | _bitBuffer; + _bitLength = _bitLength + length; + }; + + _this.flush = function() { + if (_bitLength > 0) { + _out.writeByte(_bitBuffer); + } + }; + + return _this; + }; + + var getLZWRaster = function(lzwMinCodeSize) { + + var clearCode = 1 << lzwMinCodeSize; + var endCode = (1 << lzwMinCodeSize) + 1; + var bitLength = lzwMinCodeSize + 1; + + // Setup LZWTable + var table = lzwTable(); + + for (var i = 0; i < clearCode; i += 1) { + table.add(String.fromCharCode(i) ); + } + table.add(String.fromCharCode(clearCode) ); + table.add(String.fromCharCode(endCode) ); + + var byteOut = byteArrayOutputStream(); + var bitOut = bitOutputStream(byteOut); + + // clear code + bitOut.write(clearCode, bitLength); + + var dataIndex = 0; + + var s = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + while (dataIndex < _data.length) { + + var c = String.fromCharCode(_data[dataIndex]); + dataIndex += 1; + + if (table.contains(s + c) ) { + + s = s + c; + + } else { + + bitOut.write(table.indexOf(s), bitLength); + + if (table.size() < 0xfff) { + + if (table.size() == (1 << bitLength) ) { + bitLength += 1; + } + + table.add(s + c); + } + + s = c; + } + } + + bitOut.write(table.indexOf(s), bitLength); + + // end code + bitOut.write(endCode, bitLength); + + bitOut.flush(); + + return byteOut.toByteArray(); + }; + + var lzwTable = function() { + + var _map = {}; + var _size = 0; + + var _this = {}; + + _this.add = function(key) { + if (_this.contains(key) ) { + throw 'dup key:' + key; + } + _map[key] = _size; + _size += 1; + }; + + _this.size = function() { + return _size; + }; + + _this.indexOf = function(key) { + return _map[key]; + }; + + _this.contains = function(key) { + return typeof _map[key] != 'undefined'; + }; + + return _this; + }; + + return _this; + }; + + var createDataURL = function(width, height, getPixel) { + var gif = gifImage(width, height); + for (var y = 0; y < height; y += 1) { + for (var x = 0; x < width; x += 1) { + gif.setPixel(x, y, getPixel(x, y) ); + } + } + + var b = byteArrayOutputStream(); + gif.write(b); + + var base64 = base64EncodeOutputStream(); + var bytes = b.toByteArray(); + for (var i = 0; i < bytes.length; i += 1) { + base64.writeByte(bytes[i]); + } + base64.flush(); + + return 'data:image/gif;base64,' + base64; + }; + + //--------------------------------------------------------------------- + // returns qrcode function. + + return qrcode; +}(); + +// multibyte support +!function() { + + qrcode.stringToBytesFuncs['UTF-8'] = function(s) { + // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array + function toUTF8Array(str) { + var utf8 = []; + for (var i=0; i < str.length; i++) { + var charcode = str.charCodeAt(i); + if (charcode < 0x80) utf8.push(charcode); + else if (charcode < 0x800) { + utf8.push(0xc0 | (charcode >> 6), + 0x80 | (charcode & 0x3f)); + } + else if (charcode < 0xd800 || charcode >= 0xe000) { + utf8.push(0xe0 | (charcode >> 12), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + // surrogate pair + else { + i++; + // UTF-16 encodes 0x10000-0x10FFFF by + // subtracting 0x10000 and splitting the + // 20 bits of 0x0-0xFFFFF into two halves + charcode = 0x10000 + (((charcode & 0x3ff)<<10) + | (str.charCodeAt(i) & 0x3ff)); + utf8.push(0xf0 | (charcode >>18), + 0x80 | ((charcode>>12) & 0x3f), + 0x80 | ((charcode>>6) & 0x3f), + 0x80 | (charcode & 0x3f)); + } + } + return utf8; + } + return toUTF8Array(s); + }; + +}(); + +(function (factory) { + if (typeof define === 'function' && define.amd) { + define([], factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } +}(function () { + return qrcode; +}));