One-window install: profile, wifi, USB flash and pairing hints (0.2.27)
"Nieuw scherm" used to span two dialogs plus a copy/download of the installation YAML. It is now one dialog: board, name (node name derived, editable), wifi only when secrets.yaml lacks it, the detected USB port, and Installeren. The add-on writes the profile with unique API/OTA keys, merges just the missing wifi lines into secrets.yaml (comments and other secrets untouched, result re-parsed before writing), then builds and flashes with the live ESPHome log and stage in the same dialog; a failed build offers a retry without a second profile. The YAML download endpoint is gone: the profile already lives in the ESPHome folder. Pairing happens in Home Assistant itself, so the done view and a sidebar card per not-yet-paired profile say so, with a button to Apparaten & diensten, the API key to copy and the reminder to allow HA actions. One profile per screen; the dialog says that too. Tests: wifi merge cases, install pre-checks, pending list, profile metadata; the date-anchored hourly forecast test now follows the clock. Firmware 0.2.22 stays current. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+17
-2
@@ -154,9 +154,24 @@ class ManagerTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(response.status,400)
|
||||
response=await client.put('/api/screens/text.screen',headers=headers,json={'title':'Test','tiles':[]})
|
||||
self.assertEqual(response.status,200)
|
||||
response=await client.post('/api/install',headers=headers,json={'board':'guition','name':'screen-new','friendly_name':'Nieuw'})
|
||||
from firmware import Firmware
|
||||
m.firmware=Firmware(Path(temp)/'esphome',Path(temp))
|
||||
profile={'board':'guition','name':'screen-new','friendly_name':'Nieuw','wifi_ssid':'net','wifi_password':'pw'}
|
||||
# A refused USB target leaves no profile behind.
|
||||
response=await client.post('/api/firmware/profiles',headers=headers,json={**profile,'target':'/dev/ttyUSB9'})
|
||||
self.assertEqual(response.status,400)
|
||||
self.assertFalse((Path(temp)/'esphome'/'screen-new.yaml').exists())
|
||||
response=await client.post('/api/firmware/profiles',headers=headers,json=profile)
|
||||
self.assertEqual(response.status,200)
|
||||
self.assertEqual(response.headers['Cache-Control'],'no-store')
|
||||
self.assertIn('packages/guition.yaml',await response.text())
|
||||
result=await response.json()
|
||||
self.assertEqual(result['file'],'screen-new.yaml')
|
||||
self.assertEqual(len(base64.b64decode(result['api_key'])),32)
|
||||
self.assertNotIn('job',result)
|
||||
self.assertIn('packages/guition.yaml',(Path(temp)/'esphome'/'screen-new.yaml').read_text())
|
||||
self.assertNotIn('pw',json.dumps((await (await client.get('/api/firmware')).json())['wifi']))
|
||||
# Until Home Assistant lists the screen, the page shows the profile as "not yet in HA" with its key.
|
||||
pending=(await (await client.get('/api/inventory?light=1')).json())['pending']
|
||||
self.assertEqual([(p['file'],p['friendly'],p['installed'],p['api_key']==result['api_key']) for p in pending],[('screen-new.yaml','Nieuw',False,True)])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Weather card data (hours, rain) and last-run times for scenes, scripts and buttons."""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -16,8 +16,10 @@ TZ = ZoneInfo('Europe/Amsterdam')
|
||||
NOW = datetime(2026, 9, 13, 11, 40, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def hourly():
|
||||
return [{'datetime': f'2026-09-13T{h:02d}:00:00+00:00', 'condition': 'rainy' if h < 12 else 'partlycloudy', 'temperature': 18 + h / 10,
|
||||
def hourly(day=None):
|
||||
# Whole hours from 08:00 on the given day (default: the fixed NOW day) into the next morning.
|
||||
start = (day or NOW).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return [{'datetime': (start + timedelta(hours=h)).isoformat(), 'condition': 'rainy' if h < 12 else 'partlycloudy', 'temperature': 18 + h / 10,
|
||||
'precipitation': 0.2 if h < 12 else 0.0, 'precipitation_probability': 60 if h < 12 else 5} for h in range(8, 30)]
|
||||
|
||||
|
||||
@@ -70,7 +72,8 @@ class HourlySync(unittest.IsolatedAsyncioTestCase):
|
||||
asked = []
|
||||
async def forecast(entity, kind='daily'):
|
||||
asked.append(kind)
|
||||
return daily() if kind == 'daily' else hourly()
|
||||
# The manager reads the real clock, so the hourly strip is anchored to today.
|
||||
return daily() if kind == 'daily' else hourly(datetime.now(timezone.utc))
|
||||
m.ha.forecast = forecast
|
||||
m.ha.registry.append({'entity_id': 'sensor.fw', 'platform': 'esphome', 'original_name': 'Schermfirmware'})
|
||||
m.ha.states['sensor.fw'] = {'state': '0.2.19'}
|
||||
|
||||
@@ -61,8 +61,10 @@ class ProfileNameTests(unittest.TestCase):
|
||||
'packages:\n a: !include other.yaml\napi:\n encryption:\n key: !secret api\n')
|
||||
(Path(tmp) / 'broken.yaml').write_text('esphome: [\n')
|
||||
names = f.profile_names()
|
||||
self.assertEqual(names['woonkamer.yaml'], {'node': 'woonkamer', 'friendly': 'Woonkamer'})
|
||||
self.assertEqual(names['manual.yaml'], {'node': 'keuken', 'friendly': 'Keuken'})
|
||||
self.assertEqual(names['woonkamer.yaml'], {'node': 'woonkamer', 'friendly': 'Woonkamer', 'screen': True, 'api_key': names['woonkamer.yaml']['api_key']})
|
||||
self.assertEqual(len(names['woonkamer.yaml']['api_key']), 44)
|
||||
# A manual profile (no board package from this repo, key behind !secret) is not an ESP Screens profile.
|
||||
self.assertEqual(names['manual.yaml'], {'node': 'keuken', 'friendly': 'Keuken', 'screen': False, 'api_key': None})
|
||||
self.assertNotIn('broken.yaml', names)
|
||||
self.assertNotIn('secrets.yaml', names)
|
||||
|
||||
@@ -84,7 +86,7 @@ class ProfileNameTests(unittest.TestCase):
|
||||
os.utime(b, ns=(b.stat().st_atime_ns, b.stat().st_mtime_ns + 1_000_000))
|
||||
names = f.profile_names()
|
||||
self.assertEqual(len(parsed), 3, 'only the changed file is parsed')
|
||||
self.assertEqual(names['b.yaml'], {'node': 'b2', 'friendly': 'B2'})
|
||||
self.assertEqual(names['b.yaml'], {'node': 'b2', 'friendly': 'B2', 'screen': False, 'api_key': None})
|
||||
a.unlink()
|
||||
self.assertEqual(set(f.profile_names()), {'b.yaml'})
|
||||
self.assertEqual(len(parsed), 3)
|
||||
|
||||
@@ -3,6 +3,7 @@ import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import yaml
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'screen_manager/app'))
|
||||
from firmware import Firmware
|
||||
|
||||
@@ -22,18 +23,56 @@ class WifiReuse(unittest.TestCase):
|
||||
self.assertIn('password: !secret wifi_password', profile)
|
||||
self.assertNotIn('example-private', profile)
|
||||
|
||||
def test_missing_keys_do_not_overwrite_existing_secrets(self):
|
||||
def test_missing_keys_are_added_without_touching_other_secrets(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Firmware(tmp, tmp)
|
||||
path = Path(tmp) / 'secrets.yaml'
|
||||
path.write_text('other: keep\n')
|
||||
self.assertEqual(f.wifi_status()['state'], 'missing')
|
||||
original = '# keep comments\nother: keep\nwifi_ssid: ""\n'
|
||||
path.write_text(original)
|
||||
self.assertEqual(f.wifi_status(), {'state': 'missing', 'missing': ['wifi_password', 'wifi_ssid']})
|
||||
# Without values nothing is written: no profile, secrets untouched.
|
||||
with self.assertRaises(ValueError):
|
||||
f.create({'board': 'cyd', 'name': 'screen', 'friendly_name': 'Screen'})
|
||||
self.assertEqual(path.read_text(), original)
|
||||
self.assertFalse((Path(tmp) / 'screen.yaml').exists())
|
||||
f.create({'board': 'cyd', 'name': 'screen', 'friendly_name': 'Screen',
|
||||
'wifi_ssid': 'home: net', 'wifi_password': 'p\\ss "word"'})
|
||||
text = path.read_text()
|
||||
self.assertTrue(text.startswith('# keep comments\nother: keep\n'))
|
||||
self.assertEqual(text.count('wifi_ssid'), 1)
|
||||
self.assertEqual(yaml.safe_load(text), {'other': 'keep', 'wifi_ssid': 'home: net', 'wifi_password': 'p\\ss "word"'})
|
||||
self.assertEqual(f.wifi_status()['state'], 'ready')
|
||||
self.assertTrue((Path(tmp) / 'screen.yaml').exists())
|
||||
|
||||
def test_invalid_secrets_are_never_rewritten(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Firmware(tmp, tmp)
|
||||
path = Path(tmp) / 'secrets.yaml'
|
||||
path.write_text('wifi_ssid: [invalid')
|
||||
with self.assertRaises(ValueError):
|
||||
f.create({'board': 'cyd', 'name': 'screen', 'friendly_name': 'Screen',
|
||||
'wifi_ssid': 'replacement', 'wifi_password': 'replacement'})
|
||||
self.assertEqual(path.read_text(), 'other: keep\n')
|
||||
'wifi_ssid': 'net', 'wifi_password': 'pw'})
|
||||
self.assertEqual(path.read_text(), 'wifi_ssid: [invalid')
|
||||
self.assertFalse((Path(tmp) / 'screen.yaml').exists())
|
||||
|
||||
def test_install_checks_port_and_busy_slot_before_writing(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Firmware(tmp, tmp)
|
||||
data = {'board': 'cyd', 'name': 'screen', 'friendly_name': 'Screen', 'wifi_ssid': 'net', 'wifi_password': 'pw'}
|
||||
with self.assertRaises(ValueError):
|
||||
f.install({**data, 'target': '/dev/ttyUSB0'})
|
||||
self.assertFalse((Path(tmp) / 'screen.yaml').exists())
|
||||
self.assertFalse((Path(tmp) / 'secrets.yaml').exists())
|
||||
result = f.install(data)
|
||||
self.assertEqual(result['file'], 'screen.yaml')
|
||||
self.assertEqual(len(result['api_key']), 44)
|
||||
self.assertNotIn('job', result)
|
||||
self.assertIn(result['api_key'], f.profile('screen.yaml').read_text())
|
||||
meta = f.profile_names()['screen.yaml']
|
||||
self.assertEqual((meta['screen'], meta['api_key']), (True, result['api_key']))
|
||||
(Path(tmp) / 'other.yaml').write_text('esphome:\n name: other\napi:\n encryption:\n key: !secret k\n')
|
||||
self.assertEqual((f.profile_names()['other.yaml']['screen'], f.profile_names()['other.yaml']['api_key']), (False, None))
|
||||
|
||||
def test_first_install_and_invalid_file(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
f = Firmware(tmp, tmp)
|
||||
|
||||
Reference in New Issue
Block a user