Repository navigation
Protocol
Generated page — do not edit here. The source of truth is
docs/PROTOCOL.mdin the repo, at commitc53f146. Edits made in the wiki are overwritten by the next publish, and a wiki page cannot be reviewed in the same diff as the code it describes.
This specification was recovered from Atmoph Window 2 Android app 2.3.4:
| Field | Value |
|---|---|
| Package | com.atmoph.remote |
| Version |
2.3.4 (2030400) |
| APK SHA-256 | 03e9bcbbe3ba3a1e137e48300bcc502028e902a9e07a99e5708d218404561c68 |
| APK MD5 | 57dacf3172ed84069281e485ed315d79 |
| Compile / target SDK | 36 / 35 |
| BLE stack | Nordic Semiconductor Android BLE library |
The APK advertises a Google Play distribution stamp and is signed with a Google app-signing certificate. APKs and decompiled output are not distributed by this repository.
| Label | Means |
|---|---|
| App | Read directly out of our own decompilation of 2.3.4. First-hand. |
| Cross-confirmed | Independently found on Atmoph hardware by atmoph-window-yo-ble (MIT), from live BLE traffic on a Window Yo. |
| Reported | Observed on hardware and published by samuel95207/Atmoph-HomeAssistant, which declares no license. Treated as a lead to re-verify, never as code to copy. |
No claim below is marked verified against an Atmoph Window 2 (AW102) by this project. That is issue #5.
Two models share this protocol. The app is branded "Window 2" and drives Window 2 hardware, but every published hardware observation so far was made against a Window Yo. Where the two are known to differ, it is called out.
The app actively scans for this 128-bit service (App, Cross-confirmed):
c1e0d952-12f7-4c84-b67d-fc26f55243a0
It uses the GAP name as the visible device name, connects without an
application-level authentication exchange, discovers the service, and requests
MTU 128 before its initial reads and notification subscriptions (App —
MtuRequest carries a hardcoded 128, giving a 125-byte ATT payload).
There is no pairing, bonding, or application-layer authentication (App, Cross-confirmed).
The BLE address must not be treated as identity. Two independent constraints apply, and together they rule out both obvious identifiers:
- The address rotates. Windows advertise from a resolvable private address. Rotation as fast as every ~40 seconds has been reported, which is short enough that a single slow connection attempt can outlive the address it started from. Re-resolve before each retry rather than reusing the address a attempt began with. (Reported)
- The advertised name is not on every packet. The name rides in the scan response, not the advertisement, so a passive scanner sees a nameless device and the same window appears named and nameless seconds apart. At least one scanner must use active scanning. (Reported; consistent with App, which scans actively.)
The only stable identifier is the device UUID read over GATT from
5a388825-… after connecting. It does not rotate, but it cannot be a
discovery key, because rediscovery has only an advertisement to match against
and reading the UUID needs a connection.
The integration therefore splits the two roles. Discovery and the config
entry's own unique id use the advertised name, which is the only value an
advertisement carries when it carries anything. Entities and the device
registry key on the device UUID, adopted the first time a window reports one
and then never replaced — a window answering with a different UUID is a
different window, and following it would orphan the history of the one the
entry was set up for. The switch is a registry migration, so entity ids and
their history survive it. See custom_components/atmoph_window/identity.py.
A window that reports no UUID is treated differently from one that reports the wrong one. A mismatch is refused before anything is written, because a different window answering the same name is exactly what the check exists to catch. An absent UUID is allowed through with a warning, because refusing it would be permanent — the stored identity never changes, so a window that stopped reporting one would fail every retry and the entry would never load again — and because it is weak evidence anyway: a genuinely different window answers with its own UUID rather than with nothing.
All ten are App-verified; the field letter is the obfuscated
CasterBleManager field, kept so the mapping back to decompiled source stays
checkable.
| Characteristic | Field | Direction | Android app purpose | HA use |
|---|---|---|---|---|
5a388825-de5b-45bf-8864-16be820fc169 |
o |
Read | Device UUID and name | Stable identity |
ec812b51-ae67-4cf3-8272-3967b3fc22a0 |
p |
Read, notify | Panorama role | Sensor attribute |
1d862803-b301-4548-bece-1f1ab61881b8 |
q |
Read, notify | Current view title | Sensor state |
99cd2547-0640-485c-9996-e0a2b384a6f2 |
r |
Read, notify | Current view image URL | Sensor attribute |
275ddae2-4c69-4638-97d4-d5ba8e9e05d1 |
s |
Read, notify | Current view location | Sensor attribute |
3afad096-8cb5-4cb7-a8b4-c7dca3e41b94 |
t |
Notify | Focused UI element JSON | Not implemented |
d4393824-471f-4799-ab74-28879878a4e7 |
u |
Write | Remote control | Buttons and power |
73de7799-b573-46f3-99fd-c6a7a8fc2fde |
v |
Write | Text input JSON | Not implemented |
530bcd10-f723-4203-8222-0e135022d394 |
w |
Write, notify | Quick settings JSON | Numbers and switches |
7607f5a4-22bc-4730-9019-c78dc8b50341 |
x |
Read, notify | Display power | Display switch |
5a388825-… returns "<36-char UUID>,<name>". The app slices [0:36] and
[37:], so the separator at index 36 is a comma and the name may itself
contain commas. The name here is the one set in the app, which is not the
advertised name.
ec812b51-… matches only the first character against Leader / Follower /
None, so L, F, and N are sufficient.
Present in AtmophBLEUUID and confirmed in our own decompilation, but never
assigned to a CasterBleManager field, so 2.3.4 never reads or writes them.
| Characteristic | Reported value on hardware | Looks like |
|---|---|---|
03cffbfe-b23a-4c8f-bf57-9591b4d59119 |
LAT2_IUOV6NFQ/7206c70d |
Current view id + revision — the tail of the thumbnail URL, and the only characteristic that actually tracks the view. Read best-effort; see below |
e6f3269f-a0ce-49fa-9c46-8edbc02e0711 |
a bare UUID | Device UUID on its own |
2e109d28-1008-4cb6-a7af-1fabb2fa3278 |
a device name | Device name on its own |
d78f7085-8a3e-487e-8691-9b672aeea0eb |
— | Write-only, purpose unknown |
bef2f796-7d49-48e1-9da6-f24346e6aaf7 |
— | Constructed in AtmophBLEUUID and then discarded; never stored, never used. Possibly the original Atmoph Window service. |
bef2f796-… appears in our 2.3.4 decompilation and in no published analysis.
1d862803-… carries a view title, which is what a person reads but a poor key
for an automation: titles repeat across the catalogue and follow the app's
language. 03cffbfe-… is the catalogue id, so it is the value worth having.
The evidence for it is uneven and the implementation reflects that. That the
UUID exists is App. That a window answers a read of it, and that the value
is <id>/<revision>, is Reported only — 2.3.4 never binds it, so our
decompilation says nothing about its behaviour, and no AW102 has been checked.
The integration therefore treats it as optional at every step: the subscription
may be refused, the read may fail, and the first failure stops it asking again
for the life of the connection. A window that does not implement it gets no
view-id sensor at all rather than one stuck unavailable, and setup, the poll,
and the current_view sensor are unaffected. The entity is diagnostic for the
same reason. See _read_view_id in custom_components/atmoph_window/client.py
and issue #14.
The id and the revision are exposed apart because only the id is stable: the revision moves when Atmoph re-renders a view, which would break the equality check an automation is trying to make.
The app also builds a synthetic in-process GATT table with per-characteristic property flags. Because it registers each descriptor under the characteristic's own UUID — invalid for real GATT — it is a development fixture describing what the app expects, not what a window exposes. Its flags are not evidence. Where they disagree with hardware, hardware wins; see Declared properties lie.
Dumping the GATT table off a real window turns up an entire second primary
service. The service UUID itself is absent from the app bytecode, but six of
its characteristics do appear in the decompilation's UUID table, so the app
knows of them while binding none: e9c45eb5-…, ac0c2536-…, 750b35af-…,
492783d7-…, 822962c8-…, and 2330f10b-….
Every value and behaviour below is Reported only, and none of it is implemented.
Service 401f7f45-2258-4f9b-8204-f8b301b4dcc5:
Grouped by how much can be said about each:
Interpretable. 596f4372-1456-4038-8bca-19ef89e6fe3e (read, write) carries
a one-key JSON document, IsLocked, and is the child lock. It is the only
writable characteristic in this service with an evident purpose, and no attempt
to write it has been recorded. e9c45eb5-fa81-4760-9b1b-24d6cb1d562c (read,
write, notify) and ac0c2536-1713-4be5-97e6-2c281ebb2544 (read, notify) both
carry view identifiers, the latter with a trailing boolean; see
Views can be stepped, not chosen for why
neither is usable for selection.
Shape known, purpose not. 750b35af-a702-4407-95a9-5af779a61785 (read,
write) holds 32 hexadecimal characters, consistent with a token or a digest.
d39a8ae0-a159-4efd-8ee4-c10c698f5fe2 (read, write, notify) holds a
comma-separated pair whose second field uses the same single-letter encoding as
the panorama role. e0b4d938-0dec-4e84-ace6-4d81fe4007b6 (read, write) reads
as an empty JSON object.
Unknown. 492783d7-…, 822962c8-…, b95dc23d-… and 18046ba0-… are
readable, writable and notifying, and empty.
2330f10b-d28c-4b0e-89c7-8dbd05dfa491 is write-only, so nothing can be learned
from it without writing to it.
A three-field identity variant b5b8a6c1-79fb-4220-a5b7-90bbc86a732d
(uuid, blank, name) is reported on hardware and is absent from our
decompilation entirely.
The window also implements standard Generic Access: 2a00 holds the advertised
name, and 2aa6 Central Address Resolution reads 01, consistent with the
address privacy above.
The app performs this sequence (App):
- Discover the vendor service.
- Request MTU 128.
- Read panorama role, identity, view title, image URL, and location.
- Subscribe to panorama role, view title, image URL, location, focused-view JSON, quick-settings JSON, and power.
- Write command
Cwhen the remote panel opens so the window announces state.
Home Assistant performs the same operations with three deliberate differences.
It does not request an MTU. The app asks for 128 explicitly because the Android BLE API expects it to; a bleak client cannot portably, since BlueZ and CoreBluetooth negotiate on the host's behalf. Nothing may therefore assume a payload size: a notification can be split at any byte, including the middle of a character. Both readers therefore decode incrementally — the quick-settings one, and the view title and location, which are the characteristics Atmoph fills with Japanese. A character arriving in two halves raises on both of them if each packet is decoded on its own, so the incomplete tail is held until the rest of it turns up. A long ASCII value split in two is not recoverable and nothing pretends otherwise: the payload carries no length and no terminator, so two halves cannot be told apart from two values.
It subscribes before reading, reversing steps 3 and 4, so a notification that fires while the initial reads are in flight is delivered rather than missed.
It also reads the power characteristic, which the app never does. Power notifies only on change, so nothing arrives at connect and the display state would otherwise stay unknown until someone toggled the screen. (Reported)
See initialize in custom_components/atmoph_window/client.py.
Payloads are bare UTF-8/ASCII tokens written with response. There is no length
prefix or terminator. All tokens are App-verified from the
ControlCommand enum; S, FW, BW, and C are additionally
Cross-confirmed.
| Token | App enum | Meaning | HA entity |
|---|---|---|---|
T |
Tap | Select / confirm | Select button |
DT |
DoubleTap | Double tap |
send_command service |
U |
Up | Navigate up | Up button |
D |
Down | Navigate down | Down button |
L |
Left | Navigate left | Left button |
R |
Right | Navigate right | Right button |
FW |
Forward | Next view | Next view button |
BW |
Backward | Previous view | Previous view button |
M |
Menu | Main menu | Menu button |
Q |
QuickMenu | Quick menu | Quick menu button |
B |
Back | Back | Back button |
V |
Views | Views screen | Views button |
VS |
Search | Search |
send_command service |
S |
Sleep | Toggle display sleep | Display switch |
C |
ConnectNotify | Request current state | Initialization |
SB |
ScaleBegin | Begin pinch gesture | Not implemented |
SG |
Scaling | Pinch scale factor | Not implemented |
SE |
ScaleEnd | End pinch gesture | Not implemented |
P |
Power | Declared and never sent by the app | Not implemented |
DT and VS are the two implemented tokens with no entity of their own. A
double tap and a jump to the search screen are one-shot inputs whose result
depends on what the window is already showing, and search leads to a text field
this integration deliberately cannot fill, so neither earns a button on a
dashboard. Both are sent by the atmoph_window.send_command service, which
validates its argument and refuses anything it does not recognise. The
argument is the name the integration uses, not the token or the app's enum:
double_tap and search, never DT, VS, or DoubleTap. services.yaml
offers the full list in the picker. That service also covers S and C, which entities own for other reasons:
S belongs to the display switch because only the switch performs the
read-and-confirm dance the toggle needs, and C runs at connect.
P, SB, SG, and SE are not implemented at all, so the service does not
offer them either.
SB and SE are bare tokens, but Scaling is written to the same
characteristic as a JSON object carrying the current scale factor (App):
{"SG": 1.25}The sequence is SB → {"SG": …} × n → SE. Any parser for this
characteristic must therefore tolerate both bare tokens and JSON.
The app declares P (Power) but its power button sends S, which toggles
(App). Three separate findings mean an absolute on/off is not available:
- Writing
true/falseto7607f5a4-…is silently discarded in both directions, despite the characteristic advertisingwrite. (Reported) -
Ssent within roughly a second of a previousSthat had already taken effect is dropped, with no ATT error and no state change. (Reported) - Power notifies only on change, so a dropped write is indistinguishable from a slow one without reading. (Reported)
The integration therefore reads 7607f5a4-…, sends S only if the state
differs, polls for confirmation, and retries once after a longer pause before
giving up. See set_power in custom_components/atmoph_window/client.py.
Two characteristics advertise write and discard writes: 7607f5a4-… (power,
above) and e9c45eb5-… (view id, below). Treat this device's declared GATT
properties as a weak hint and confirm behaviour against reported state.
No characteristic selects a view. FW and BW advance and retreat through the
catalogue one entry at a time, and that is the entire navigation surface this
protocol offers.
Two characteristics hold something resembling a view identifier, and it is worth being precise about which does what, because the naming invites the wrong conclusion:
-
03cffbfe-…, in the main service, is the one that actually follows the display. Its value is<id>/<revision>— the same pair the thumbnail URL path is built from. It is read and notify only, so it reports and cannot be commanded. -
e9c45eb5-…, in the second service, declares write as well, and its value has the shape of an identifier. Neither fact means what it appears to. Reported testing found its value frozen on one identifier while the display moved through several, and found writes to it — whether an identifier alone, an identifier with a revision, or the identifier already on screen — acknowledged at the transport level while changing neither the display nor the characteristic's own value.
So a view-selection entity is not implementable today. It would require either
a command nobody has found or a purpose for one of the two write-only
characteristics that remain unidentified, 2330f10b-… and d78f7085-….
Tracked as
issue #7.
Everything in this section beyond the two UUIDs and their declared properties is Reported and has not been reproduced here.
The app writes a compact single-key JSON object to 530bcd10-… (App):
{"ScreenBrightness":6}Notifications contain the complete settings object. Booleans are JSON booleans; levels are objects with device-provided bounds:
{"ScreenBrightness":{"min":1,"max":10,"value":6}}Known keys (App, from QuickMenuSetting), with how each is exposed:
| Key | Shape | HA surface |
|---|---|---|
WidgetsVisible |
boolean | Switch |
DailyRoutineEnable |
boolean | Switch |
SoundOnly |
boolean | Switch |
LandscapeVolumeLevel |
level | Number |
SoundscapeVolumeLevel |
level | Number |
ScreenBrightness |
level | Number |
LedBrightness |
level | Number |
SoundscapeLayer |
level |
set_setting service |
CurrentDecoration |
level |
set_setting service |
SoundscapeLayer and CurrentDecoration are levels by wire format but choices
by meaning: neither is a magnitude a slider communicates honestly, and nothing
recovered from the app names their members, so a select would have to invent
labels. Both stay writable through atmoph_window.set_setting, which validates
the key against the table above and a level against the bounds the window
itself reported, refusing to write a setting the window has never reported at
all — there is nothing in the write format to say whether such a key is a
boolean or a level. See
issue #15.
Never hardcode a 0–10 range. One reported device:
| Setting | min | max |
|---|---|---|
ScreenBrightness |
1 | 25 |
LandscapeVolumeLevel |
0 | 24 |
SoundscapeVolumeLevel |
0 | 20 |
LedBrightness |
0 | 20 |
CurrentDecoration |
0 | 19 |
SoundscapeLayer |
0 | 5 |
Read the bounds from the device and use them. (Reported)
JSON may span multiple ATT notifications. The app accumulates chunks and
retries a parse after each one, discarding the buffer after 20 consecutive
failures (TimeoutJsonMerger); the focused-view characteristic uses Nordic's
brace-counting JsonMerger (App). Any implementation should reassemble by
"accumulate until it parses". The integration buffers partial objects, accepts
concatenated complete objects, and enforces a size limit.
A setting write is echoed back on the notify channel, reportedly within about
1.5 s. That echo is the most reliable way to test the write path, because
DailyRoutineEnable makes the window rotate views on its own — so an observed
view change is not proof that an FW / BW write landed. (Reported)
The app writes JSON shaped as (App):
{"type":"text","extra":{"text":"<encoded value>","input_mode":"search"}}and ends input with:
{"type":"text_done","extra":{}}input_mode is one of password, credit_card_number, credit_card_month,
credit_card_year, credit_card_cvc, text, search, pin
(InputMode.java).
The value is AES-encrypted, then Base64-encoded, using a key hardcoded in the
app (TextCipher.java). Its purpose is to keep typed passwords off the air in
cleartext, not to authenticate anything. The key is recoverable from the APK by
anyone repeating the decompilation, and is deliberately not reproduced in
this repository. Text entry is not required for the Home Assistant control
surface and is not implemented.
Nothing here has been checked against an AW102 by this project. Doing so is issue #5.
- Confirm the service is advertised by an AW102 running current firmware.
- Dump the real GATT table, both services, and compare properties with this map.
- Confirm whether Window 2 exposes
401f7f45-…at all, or whether it is Window Yo only. - Read identity, view metadata, quick settings, and power; record real bounds.
- Read
03cffbfe-…and record whether an AW102 answers it at all, whether it notifies, and whether the value really is<id>/<revision>. The view-id sensor rests entirely on this and nothing else in the integration does (issue #14). - Send
Cand confirm notifications arrive. - Test every implemented command once, with the official app closed.
- Verify
Sin both directions, then verify the dropped-toggle window by sending two in quick succession. - Confirm power writes are discarded, so the toggle path stays justified.
- Capture an HCI snoop log and promote App claims to hardware-verified.
- Observe address rotation and reconnect through a Home Assistant Bluetooth proxy.
See ANDROID-APP-ANALYSIS.md for acquisition, tooling, and the class-by-class walkthrough.
Whether the window exposes any network control surface was settled in
issue #6,
now closed: a full 1–65535 TCP scan of a real unit found one open port,
53/tcp, silent to both a banner grab and an HTTP request, and every Android
diagnostic port closed. The app is BLE-only and so is the window, so this
integration is too.