pyterm-pixel 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. pyterm/__init__.py +3 -0
  2. pyterm/__main__.py +45 -0
  3. pyterm/alarms.py +499 -0
  4. pyterm/app.py +1424 -0
  5. pyterm/backend.py +316 -0
  6. pyterm/backgrounds.py +940 -0
  7. pyterm/bench.py +69 -0
  8. pyterm/boxdraw.py +242 -0
  9. pyterm/config.py +244 -0
  10. pyterm/cursors.py +464 -0
  11. pyterm/data/config.yaml +61 -0
  12. pyterm/data/fonts/README.md +2 -0
  13. pyterm/data/fonts/sparkle.yaml +7 -0
  14. pyterm/data/fonts/sparkle_0.png +0 -0
  15. pyterm/data/fonts/sparkle_1.png +0 -0
  16. pyterm/data/fonts/sparkle_2.png +0 -0
  17. pyterm/data/fonts/sparkle_3.png +0 -0
  18. pyterm/data/plugins/_template.py +35 -0
  19. pyterm/data/plugins/cursor_demo.py +106 -0
  20. pyterm/data/plugins/example_effect.py +60 -0
  21. pyterm/data/plugins/fireworks.py +75 -0
  22. pyterm/data/plugins/hud_meter.py +37 -0
  23. pyterm/data/plugins/party_alarm.py +56 -0
  24. pyterm/data/plugins/sine_scroller.py +47 -0
  25. pyterm/data/plugins/sixamp_support.py +75 -0
  26. pyterm/data/plugins/walkers.py +311 -0
  27. pyterm/data/shell/pyterm.bash +19 -0
  28. pyterm/data/shell/pyterm.ps1 +37 -0
  29. pyterm/data/shell/pyterm.zsh +15 -0
  30. pyterm/data/sounds/README.md +3 -0
  31. pyterm/data/themes/amber_crt.yaml +16 -0
  32. pyterm/data/themes/amiga.yaml +22 -0
  33. pyterm/data/themes/amigademo.yaml +23 -0
  34. pyterm/data/themes/aquarium.yaml +21 -0
  35. pyterm/data/themes/autumn.yaml +21 -0
  36. pyterm/data/themes/beach.yaml +21 -0
  37. pyterm/data/themes/c64.yaml +26 -0
  38. pyterm/data/themes/c64_40.yaml +33 -0
  39. pyterm/data/themes/c64_raw.yaml +33 -0
  40. pyterm/data/themes/c64load.yaml +23 -0
  41. pyterm/data/themes/christmas.yaml +22 -0
  42. pyterm/data/themes/computerpeople.yaml +21 -0
  43. pyterm/data/themes/construction.yaml +14 -0
  44. pyterm/data/themes/demoscene.yaml +19 -0
  45. pyterm/data/themes/discocity.yaml +20 -0
  46. pyterm/data/themes/dos.yaml +14 -0
  47. pyterm/data/themes/easter.yaml +22 -0
  48. pyterm/data/themes/emerald_mine.yaml +19 -0
  49. pyterm/data/themes/gameboy.yaml +27 -0
  50. pyterm/data/themes/garden.yaml +22 -0
  51. pyterm/data/themes/halloween.yaml +21 -0
  52. pyterm/data/themes/inferno.yaml +17 -0
  53. pyterm/data/themes/interlace.yaml +15 -0
  54. pyterm/data/themes/invaders.yaml +22 -0
  55. pyterm/data/themes/jumpnrun.yaml +21 -0
  56. pyterm/data/themes/liquid.yaml +15 -0
  57. pyterm/data/themes/matrix.yaml +17 -0
  58. pyterm/data/themes/neon.yaml +18 -0
  59. pyterm/data/themes/neonsigns.yaml +22 -0
  60. pyterm/data/themes/nes_famicom.yaml +32 -0
  61. pyterm/data/themes/newyear.yaml +22 -0
  62. pyterm/data/themes/pinball.yaml +21 -0
  63. pyterm/data/themes/roman.yaml +23 -0
  64. pyterm/data/themes/rotozoom.yaml +14 -0
  65. pyterm/data/themes/sakura.yaml +22 -0
  66. pyterm/data/themes/sandstorm.yaml +15 -0
  67. pyterm/data/themes/seasonal.yaml +21 -0
  68. pyterm/data/themes/settlers.yaml +21 -0
  69. pyterm/data/themes/shadebobs.yaml +15 -0
  70. pyterm/data/themes/spacestation.yaml +21 -0
  71. pyterm/data/themes/trains.yaml +21 -0
  72. pyterm/data/themes/vaporwave.yaml +17 -0
  73. pyterm/data/themes/walkers.yaml +23 -0
  74. pyterm/data/themes/watermirror.yaml +22 -0
  75. pyterm/data/themes/weather.yaml +17 -0
  76. pyterm/data/themes/winter.yaml +21 -0
  77. pyterm/data/themes/zen.yaml +13 -0
  78. pyterm/demo.py +306 -0
  79. pyterm/effects.py +917 -0
  80. pyterm/emoji.py +352 -0
  81. pyterm/fontdata.py +162 -0
  82. pyterm/fonts.py +1139 -0
  83. pyterm/images.py +289 -0
  84. pyterm/imgcat.py +50 -0
  85. pyterm/keys.py +83 -0
  86. pyterm/mine.py +309 -0
  87. pyterm/minercursor.py +334 -0
  88. pyterm/mqtt.py +543 -0
  89. pyterm/pane.py +641 -0
  90. pyterm/particles.py +204 -0
  91. pyterm/plugins.py +320 -0
  92. pyterm/post.py +255 -0
  93. pyterm/present.py +258 -0
  94. pyterm/recorder.py +141 -0
  95. pyterm/renderer.py +262 -0
  96. pyterm/roman.py +157 -0
  97. pyterm/scenes.py +1754 -0
  98. pyterm/scenes2.py +1265 -0
  99. pyterm/scroller.py +58 -0
  100. pyterm/seasons.py +1029 -0
  101. pyterm/seasons2.py +517 -0
  102. pyterm/shell.py +33 -0
  103. pyterm/signals.py +56 -0
  104. pyterm/sound.py +173 -0
  105. pyterm/splash.py +302 -0
  106. pyterm/studio.py +900 -0
  107. pyterm/themes.py +183 -0
  108. pyterm/vt.py +1024 -0
  109. pyterm/water.py +174 -0
  110. pyterm/weather.py +1159 -0
  111. pyterm/yamlio.py +40 -0
  112. pyterm_pixel-1.1.0.dist-info/METADATA +348 -0
  113. pyterm_pixel-1.1.0.dist-info/RECORD +118 -0
  114. pyterm_pixel-1.1.0.dist-info/WHEEL +5 -0
  115. pyterm_pixel-1.1.0.dist-info/entry_points.txt +2 -0
  116. pyterm_pixel-1.1.0.dist-info/licenses/COPYING +674 -0
  117. pyterm_pixel-1.1.0.dist-info/licenses/COPYING.LESSER +165 -0
  118. pyterm_pixel-1.1.0.dist-info/top_level.txt +1 -0
pyterm/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """pyterm - a pygame pixel-art overkill terminal emulator."""
2
+ __version__ = '1.1.0'
3
+ __license__ = 'LGPL-3.0-only'
pyterm/__main__.py ADDED
@@ -0,0 +1,45 @@
1
+ import os
2
+ import sys
3
+
4
+
5
+ def main(argv=None):
6
+ from .config import build_config
7
+ cfg, args = build_config(argv)
8
+ if args.benchmark:
9
+ os.environ.setdefault('SDL_VIDEODRIVER', 'dummy')
10
+ os.environ.setdefault('SDL_AUDIODRIVER', 'dummy')
11
+ from .bench import run_benchmark
12
+ return run_benchmark(cfg, args)
13
+ if args.screenshot:
14
+ os.environ.setdefault('SDL_AUDIODRIVER', 'dummy')
15
+ import pygame # noqa
16
+ if args.list:
17
+ pygame.init()
18
+ from . import backgrounds, cursors, effects, fonts, themes
19
+ print('themes :', ', '.join(themes.list_themes()))
20
+ print('letter fx :', ', '.join(effects.APPEAR))
21
+ print('delete fx :', ', '.join(effects.VANISH))
22
+ print('cursors :', ', '.join(cursors.CURSORS))
23
+ print('cursor fx :', ', '.join(cursors.CURSOR_EFFECTS))
24
+ from . import splash
25
+ print('splashes :', ', '.join(splash.SPLASHES))
26
+ print('backgrounds:', ', '.join(backgrounds.BACKGROUNDS), '(+ mine via --miner)')
27
+ print('fonts :', ', '.join(fonts.list_fonts()))
28
+ return 0
29
+ if args.export_font:
30
+ pygame.init()
31
+ pygame.display.set_mode((1, 1), pygame.HIDDEN) if hasattr(pygame, 'HIDDEN') else None
32
+ from . import fonts
33
+ f = fonts.load_font(cfg.get('font') or 'builtin', cfg.get('font_size') or 18, cfg.get('antialias') is not False)
34
+ pf = fonts.PixelFont.from_font(f)
35
+ name = os.path.splitext(os.path.basename(args.export_font))[0]
36
+ p = pf.save(name)
37
+ print('exported %s (+ %s.png) - edit the PNG, then run with --font %s' % (p, name, os.path.relpath(p)))
38
+ return 0
39
+ from .app import App
40
+ App(cfg, args).run()
41
+ return 0
42
+
43
+
44
+ if __name__ == '__main__':
45
+ sys.exit(main() or 0)
pyterm/alarms.py ADDED
@@ -0,0 +1,499 @@
1
+ """Alarms - sound + full-window display effect, triggered from Home Assistant (MQTT), the demo shell
2
+ (`alarm redalert Washing machine done`) or a plugin (app.trigger_alarm('doorbell', 'Someone is at the door')).
3
+
4
+ Built in:
5
+ redalert rotating red beacons, pulsing red screen edges, a klaxon, big ALERT card
6
+ warning yellow/black hazard tape crawling around the border, an urgent beep-beep
7
+ doorbell a friendly ding-dong, a bell swinging on a card that slides in
8
+ timer an old alarm clock shaking and ringing like crazy
9
+ calm a soft blue breathing glow and gentle rising tones (wake-up / reminder)
10
+
11
+ Any key or mouse click dismisses an alarm (the key is not passed to the program); it also stops by
12
+ itself after `duration` seconds. Alarms ring even when the theme's key sounds are off - only a
13
+ missing audio device or --no-sound keeps them silent.
14
+
15
+ Write your own:
16
+
17
+ from pyterm.alarms import Alarm
18
+
19
+ class Party(Alarm):
20
+ duration = 10
21
+ color = (255, 0, 255)
22
+ def sound(self, snd): # -> (pygame Sound, repeat seconds) or None
23
+ ...
24
+ def draw(self, surf, app): # virtual pixel surface, after everything else
25
+ ...
26
+
27
+ def register(api):
28
+ api.register_alarm('party', Party)
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import math
33
+ import time
34
+
35
+ import numpy as np
36
+ import pygame
37
+
38
+ ADD = pygame.BLEND_RGB_ADD
39
+
40
+
41
+ class Alarm:
42
+ name = 'base'
43
+ duration = 30.0
44
+ color = (255, 60, 60)
45
+ title = 'ALERT'
46
+ label = None # Home Assistant button name ("Alarm <label>"), default: the registered name
47
+ ha_icon = 'mdi:alarm-light'
48
+
49
+ def __init__(self, app, message='', duration=None, sound=True):
50
+ self.app = app
51
+ self.message = (message or '').strip()
52
+ self.t = 0.0
53
+ self.done = False
54
+ self.dismissed = False
55
+ self.duration = float(duration) if duration else self.duration
56
+ self.snd = None
57
+ self.repeat = 0.0
58
+ self.next_sound = 0.0
59
+ if sound and app.sound.ok and not app.cfg.get('sound') is False:
60
+ try:
61
+ r = self.sound(app.sound)
62
+ if r:
63
+ self.snd, self.repeat = r
64
+ except Exception as e:
65
+ print('[pyterm] alarm sound: %s' % e)
66
+ self.start()
67
+
68
+ # ---- override
69
+ def start(self):
70
+ pass
71
+
72
+ def sound(self, snd):
73
+ return None
74
+
75
+ def draw(self, surf, app):
76
+ pass
77
+
78
+ # ---- machinery
79
+ def update(self, dt):
80
+ self.t += dt
81
+ if self.snd is not None and self.t >= self.next_sound:
82
+ ch = pygame.mixer.find_channel(True)
83
+ if ch:
84
+ ch.set_volume(max(0.35, self.app.sound.volume))
85
+ ch.play(self.snd)
86
+ self.next_sound = self.t + (self.repeat or 1e9)
87
+ if self.t >= self.duration:
88
+ self.stop()
89
+
90
+ def stop(self, dismissed=False):
91
+ if self.done:
92
+ return
93
+ self.done = True
94
+ self.dismissed = dismissed
95
+ if self.snd is not None:
96
+ self.snd.stop()
97
+
98
+ # ---- helpers for subclasses
99
+ def big(self, app, text, color, k=2, shadow=True):
100
+ """text in the terminal font, scaled up k times (blocky), with a hard drop shadow"""
101
+ cw, ch = app.font.cw, app.font.ch
102
+ s = pygame.Surface((len(text) * cw + 1, ch + 1), pygame.SRCALPHA)
103
+ if shadow:
104
+ app.draw_text(s, 1, 1, text, (0, 0, 0))
105
+ app.draw_text(s, 0, 0, text, color)
106
+ return pygame.transform.scale(s, (s.get_width() * k, s.get_height() * k))
107
+
108
+ def card(self, surf, app, title, color, y=None, icon=None, alpha=220, k=None):
109
+ """centred message card: big blocky title, the (wrapped) message, a blinking hint and an optional
110
+ icon(surf, x, y, size) drawn in a column on the left"""
111
+ cw, ch = app.font.cw, app.font.ch
112
+ W, H = surf.get_size()
113
+ k = k or max(2, min(3, W // (cw * (len(title) + 10) * 2) + 1))
114
+ ts = self.big(app, title, color, k)
115
+ lines = _wrap(self.message, max(16, min(48, W // cw - 16)))[:4]
116
+ hint = 'press any key'
117
+ pad = cw * 2
118
+ isz = ch * 3 if icon else 0
119
+ tw = max([ts.get_width()] + [len(x) * cw for x in lines + [hint]])
120
+ w = tw + pad * 2 + (isz + pad if icon else 0)
121
+ h = pad + ts.get_height() + ch // 2 + len(lines) * ch + ch * 2 + pad // 2
122
+ h = max(h, isz + pad * 2)
123
+ x = (W - w) // 2
124
+ y = (H - h) // 2 if y is None else y
125
+ panel = pygame.Surface((w, h), pygame.SRCALPHA)
126
+ panel.fill((8, 6, 12, alpha))
127
+ surf.blit(panel, (x, y))
128
+ lw = max(1, ch // 8)
129
+ glow = 0.6 + 0.4 * math.sin(self.t * 6)
130
+ pygame.draw.rect(surf, tuple(int(c * glow) for c in color), (x - lw * 2, y - lw * 2, w + lw * 4, h + lw * 4), lw)
131
+ pygame.draw.rect(surf, color, (x, y, w, h), lw)
132
+ tx = x + pad + (isz + pad if icon else 0)
133
+ cx = tx + tw // 2
134
+ if icon:
135
+ icon(surf, x + pad, y + (h - isz) // 2, isz)
136
+ yy = y + pad
137
+ surf.blit(ts, (cx - ts.get_width() // 2, yy))
138
+ yy += ts.get_height() + ch // 2
139
+ for line in lines:
140
+ app.draw_text(surf, cx - len(line) * cw // 2, yy, line, (255, 255, 255))
141
+ yy += ch
142
+ if int(self.t * 2) % 2 == 0:
143
+ app.draw_text(surf, cx - len(hint) * cw // 2, yy + ch // 2, hint, (140, 140, 155))
144
+ return pygame.Rect(x, y, w, h)
145
+
146
+ def edge(self, surf, color, width, k=1.0):
147
+ """soft additive glow along the window edges (cached gradient, scaled by k)"""
148
+ W, H = surf.get_size()
149
+ key = (W, H, width, color)
150
+ g = _EDGE.get(key)
151
+ if g is None:
152
+ _EDGE.clear()
153
+ x = np.minimum(np.arange(W), np.arange(W)[::-1])[:, None]
154
+ y = np.minimum(np.arange(H), np.arange(H)[::-1])[None, :]
155
+ d = np.minimum(x, y).astype(np.float32)
156
+ a = np.clip(1 - d / width, 0, 1) ** 2
157
+ g = pygame.Surface((W, H))
158
+ pygame.surfarray.blit_array(g, (a[..., None] * np.array(color, np.float32)).astype(np.uint8))
159
+ _EDGE[key] = g
160
+ if k >= 0.99:
161
+ surf.blit(g, (0, 0), special_flags=ADD)
162
+ elif k > 0.01:
163
+ c = g.copy()
164
+ v = int(255 * k)
165
+ c.fill((v, v, v), special_flags=pygame.BLEND_RGB_MULT)
166
+ surf.blit(c, (0, 0), special_flags=ADD)
167
+
168
+ @staticmethod
169
+ def tint(surf, rgb):
170
+ """multiply the whole picture (rgb 0..255 per channel) - darken / color-grade what's behind"""
171
+ surf.fill(rgb, special_flags=pygame.BLEND_RGB_MULT)
172
+
173
+
174
+ _EDGE = {}
175
+
176
+
177
+ def _wrap(text, n):
178
+ out, line = [], ''
179
+ for w in (text or '').split():
180
+ while len(w) > n:
181
+ if line:
182
+ out.append(line)
183
+ line = ''
184
+ out.append(w[:n])
185
+ w = w[n:]
186
+ if line and len(line) + 1 + len(w) > n:
187
+ out.append(line)
188
+ line = w
189
+ else:
190
+ line = (line + ' ' + w).strip()
191
+ if line:
192
+ out.append(line)
193
+ return out
194
+
195
+
196
+ def _env(n, a=0.01, r=0.05, sr=44100):
197
+ e = np.ones(n)
198
+ na, nr = min(int(a * sr), n // 2), min(int(r * sr), n // 2)
199
+ if na:
200
+ e[:na] = np.linspace(0, 1, na)
201
+ if nr:
202
+ e[-nr:] = np.linspace(1, 0, nr)
203
+ return e
204
+
205
+
206
+ def _tone(f, dur, wave='sine', sr=44100, vol=1.0):
207
+ t = np.arange(int(sr * dur)) / sr
208
+ if wave == 'square':
209
+ s = np.sign(np.sin(2 * np.pi * f * t))
210
+ elif wave == 'saw':
211
+ s = 2 * ((t * f) % 1) - 1
212
+ else:
213
+ s = np.sin(2 * np.pi * f * t)
214
+ return s * _env(len(t)) * vol
215
+
216
+
217
+ # ============================================================================ red alert
218
+ class RedAlert(Alarm):
219
+ label = 'red alert'
220
+ ha_icon = 'mdi:alarm-light'
221
+ name = 'redalert'
222
+ duration = 30.0
223
+ color = (255, 50, 40)
224
+
225
+ def sound(self, snd):
226
+ sr = 44100
227
+ t = np.arange(int(sr * 1.4)) / sr
228
+ f = 520 + 380 * np.abs(np.sin(np.pi * t / 1.4)) # whooping klaxon sweep
229
+ ph = 2 * np.pi * np.cumsum(f) / sr
230
+ s = (np.sign(np.sin(ph)) * 0.5 + np.sin(ph * 2) * 0.3) * _env(len(t), 0.02, 0.1)
231
+ return snd._mk(s, 0.9), 1.6
232
+
233
+ def draw(self, surf, app):
234
+ W, H = surf.get_size()
235
+ on = 0.5 + 0.5 * math.sin(self.t * 7)
236
+ # grade everything red and darker, pulsing with the klaxon
237
+ self.tint(surf, (int(170 + 70 * on), int(40 + 30 * on), int(36 + 26 * on)))
238
+ self.edge(surf, (255, 30, 16), max(12, min(W, H) // 6), 0.35 + 0.65 * on)
239
+ # rotating beacon on top: two light cones swept around
240
+ r = max(6, min(W, H) // 28)
241
+ bx, by = W // 2, r + 2
242
+ beams = pygame.Surface((W, H))
243
+ beams.fill((0, 0, 0))
244
+ L = math.hypot(W, H)
245
+ for k in (0, math.pi):
246
+ a = self.t * 4.5 + k
247
+ for spread, col in ((0.30, (60, 8, 4)), (0.16, (110, 18, 8)), (0.06, (160, 40, 20))):
248
+ pygame.draw.polygon(beams, col, [(bx, by), (bx + math.cos(a - spread) * L, by + math.sin(a - spread) * L),
249
+ (bx + math.cos(a + spread) * L, by + math.sin(a + spread) * L)])
250
+ surf.blit(beams, (0, 0), special_flags=ADD)
251
+ pygame.draw.rect(surf, (40, 40, 48), (bx - r * 2, 0, r * 4, r // 2 + 2)) # base
252
+ pygame.draw.circle(surf, (200, 20, 10), (bx, by), r)
253
+ pygame.draw.circle(surf, (255, 120, 90), (bx - r // 3, by - r // 3), max(1, r // 3))
254
+ flash = abs(math.cos(self.t * 4.5))
255
+ pygame.draw.circle(surf, (int(255 * flash), int(200 * flash), int(170 * flash)), (bx, by), max(1, r // 2))
256
+ self.card(surf, app, 'RED ALERT', (255, 70, 50), icon=_icon_siren(self.t))
257
+
258
+
259
+ # ============================================================================ warning (hazard tape)
260
+ class Warning(Alarm):
261
+ label = 'warning'
262
+ ha_icon = 'mdi:alert'
263
+ name = 'warning'
264
+ duration = 20.0
265
+ color = (255, 210, 40)
266
+
267
+ def sound(self, snd):
268
+ s = np.concatenate([_tone(1800, 0.12, 'square'), np.zeros(4410), _tone(1800, 0.12, 'square'), np.zeros(26000)])
269
+ return snd._mk(s, 0.6), 0.9
270
+
271
+ def draw(self, surf, app):
272
+ W, H = surf.get_size()
273
+ self.tint(surf, (170, 160, 140))
274
+ band = max(8, min(W, H) // 16)
275
+ stripe = band
276
+ key = (W, H, band)
277
+ if getattr(self, '_tk', None) != key: # long tape strip, scrolled every frame
278
+ self._tk = key
279
+ n = W + H + stripe * 4
280
+ t = pygame.Surface((n, band))
281
+ t.fill((255, 200, 0))
282
+ for x in range(-band, n, stripe * 2):
283
+ pygame.draw.polygon(t, (24, 22, 20), [(x, 0), (x + stripe, 0), (x + stripe - band, band), (x - band, band)])
284
+ t.fill((255, 235, 120), (0, 0, n, max(1, band // 10)))
285
+ t.fill((120, 90, 0), (0, band - max(1, band // 10), n, max(1, band // 10)))
286
+ self._tape = t
287
+ off = int(self.t * band * 2) % (stripe * 2)
288
+ t = self._tape
289
+ surf.blit(t, (0, 0), (off, 0, W, band))
290
+ surf.blit(t, (0, H - band), (stripe * 2 - off, 0, W, band))
291
+ side = pygame.transform.rotate(t.subsurface((0, 0, H + stripe * 2, band)), 90)
292
+ surf.blit(side, (0, 0), (0, off, band, H))
293
+ surf.blit(side, (W - band, 0), (0, stripe * 2 - off, band, H))
294
+ blink = int(self.t * 3) % 2 == 0
295
+ for cx in (band // 2, W - band // 2): # flashing amber lamps in the corners
296
+ for cy in (band // 2, H - band // 2):
297
+ pygame.draw.circle(surf, (255, 170, 0) if blink else (90, 50, 0), (cx, cy), band // 2 + 1)
298
+ self.card(surf, app, 'WARNING', (255, 205, 30), icon=_icon_hazard(self.t))
299
+
300
+
301
+ # ============================================================================ doorbell
302
+ class Doorbell(Alarm):
303
+ label = 'doorbell'
304
+ ha_icon = 'mdi:doorbell'
305
+ name = 'doorbell'
306
+ duration = 8.0
307
+ color = (120, 200, 255)
308
+
309
+ def sound(self, snd):
310
+ sr = 44100
311
+ ding = sum(np.sin(2 * np.pi * 660 * h * np.arange(int(sr * 1.2)) / sr) * a for h, a in ((1, 1), (2.76, 0.3), (5.4, 0.1)))
312
+ dong = sum(np.sin(2 * np.pi * 523 * h * np.arange(int(sr * 1.6)) / sr) * a for h, a in ((1, 1), (2.76, 0.3), (5.4, 0.1)))
313
+ ding *= np.exp(-np.arange(len(ding)) / sr * 3)
314
+ dong *= np.exp(-np.arange(len(dong)) / sr * 2.5)
315
+ s = np.concatenate([ding[:int(sr * 0.45)], dong])
316
+ return snd._mk(s, 0.8), 99
317
+
318
+ def draw(self, surf, app):
319
+ W, H = surf.get_size()
320
+ ch = app.font.ch
321
+ slide = min(1.0, self.t / 0.45)
322
+ e = 1 - (1 - slide) ** 3 + math.sin(slide * math.pi) * 0.08 # ease out with a small bounce
323
+ self.edge(surf, (40, 110, 200), max(10, min(W, H) // 8), 0.5 + 0.3 * math.sin(self.t * 3))
324
+ y0 = -ch * 10
325
+ self.card(surf, app, 'DING DONG', (120, 200, 255), y=int(y0 + (ch * 3 - y0) * e), icon=_icon_bell(self.t))
326
+
327
+
328
+ # ============================================================================ timer (alarm clock)
329
+ class Timer(Alarm):
330
+ label = 'timer'
331
+ ha_icon = 'mdi:timer-alert'
332
+ name = 'timer'
333
+ duration = 30.0
334
+ color = (255, 140, 60)
335
+
336
+ def sound(self, snd):
337
+ sr = 44100
338
+ ring = np.concatenate([_tone(2600 if i % 2 else 2300, 0.035, 'square') for i in range(40)] + [np.zeros(int(sr * 0.4))])
339
+ return snd._mk(ring, 0.55), 1.8
340
+
341
+ def draw(self, surf, app):
342
+ W, H = surf.get_size()
343
+ self.tint(surf, (190, 180, 170))
344
+ ringing = (self.t % 1.8) < 1.4
345
+ self.edge(surf, (255, 120, 30), max(10, min(W, H) // 8), 0.8 if ringing else 0.25)
346
+ r = max(14, min(W, H) // 8)
347
+ rect = self.card(surf, app, 'TIME IS UP', (255, 150, 60), y=H // 2 - r // 2)
348
+ _clock(surf, W // 2, rect.top - int(r * 1.45), r, self.t, ringing)
349
+
350
+
351
+ # ============================================================================ calm
352
+ class Calm(Alarm):
353
+ label = 'calm reminder'
354
+ ha_icon = 'mdi:bell-ring-outline'
355
+ name = 'calm'
356
+ duration = 20.0
357
+ color = (100, 170, 255)
358
+
359
+ def sound(self, snd):
360
+ sr = 44100
361
+ notes = [523.25, 659.25, 783.99, 1046.5]
362
+ parts = []
363
+ for f in notes:
364
+ n = int(sr * 0.9)
365
+ t = np.arange(n) / sr
366
+ parts.append(np.sin(2 * np.pi * f * t) * np.exp(-t * 3) * 0.8 + np.sin(4 * np.pi * f * t) * np.exp(-t * 5) * 0.2)
367
+ s = np.zeros(int(sr * 3.2))
368
+ for i, p in enumerate(parts):
369
+ st = int(i * sr * 0.45)
370
+ s[st:st + len(p)] += p
371
+ return snd._mk(s, 0.5), 4.5
372
+
373
+ def draw(self, surf, app):
374
+ W, H = surf.get_size()
375
+ b = 0.5 + 0.5 * math.sin(self.t * 1.3)
376
+ fade = min(1.0, self.t / 1.5)
377
+ v = int(255 - 60 * fade)
378
+ self.tint(surf, (int(v * 0.86), int(v * 0.93), v))
379
+ self.edge(surf, (40, 110, 255), max(16, min(W, H) // 4), (0.35 + 0.65 * b) * fade)
380
+ self.card(surf, app, 'REMINDER', (130, 190, 255), alpha=185, icon=_icon_moon(self.t))
381
+
382
+
383
+
384
+ # ---------------------------------------------------------------------------- card icons: f(surf, x, y, size)
385
+ def _icon_siren(t):
386
+ def f(s, x, y, n):
387
+ cx = x + n // 2
388
+ on = int(t * 6) % 2
389
+ pygame.draw.rect(s, (60, 60, 70), (x + n // 8, y + n * 3 // 4, n * 3 // 4, n // 6))
390
+ pygame.draw.ellipse(s, (255, 40, 30) if on else (120, 10, 10), (x + n // 5, y + n // 6, n * 3 // 5, n * 3 // 4))
391
+ s.fill((60, 60, 70), (x + n // 5, y + n * 9 // 16, n * 3 // 5, n // 5))
392
+ pygame.draw.circle(s, (255, 200, 180), (cx - n // 9, y + n // 3), max(1, n // 12))
393
+ if on:
394
+ for a in (-0.9, -0.45, 0, 0.45, 0.9):
395
+ ang = a - math.pi / 2
396
+ pygame.draw.line(s, (255, 120, 90), (cx + math.cos(ang) * n * 0.48, y + n * 0.45 + math.sin(ang) * n * 0.48),
397
+ (cx + math.cos(ang) * n * 0.62, y + n * 0.45 + math.sin(ang) * n * 0.62), max(1, n // 16))
398
+ return f
399
+
400
+
401
+ def _icon_hazard(t):
402
+ def f(s, x, y, n):
403
+ pts = [(x + n // 2, y + n // 10), (x + n - 1, y + n - n // 10), (x, y + n - n // 10)]
404
+ pygame.draw.polygon(s, (24, 22, 20), [(px, py + 2) for px, py in pts])
405
+ pygame.draw.polygon(s, (255, 205, 30), pts)
406
+ lw = max(2, n // 9)
407
+ s.fill((24, 22, 20), (x + n // 2 - lw // 2, y + n * 7 // 20, lw, n * 3 // 10))
408
+ s.fill((24, 22, 20), (x + n // 2 - lw // 2, y + n * 3 // 4, lw, lw))
409
+ return f
410
+
411
+
412
+ def _icon_bell(t):
413
+ def f(s, x, y, n):
414
+ a = math.sin(t * 14) * 0.45 * max(0.0, 1 - t / 2.2)
415
+ cx, cy = x + n / 2, y + n * 0.12
416
+
417
+ def rot(px, py):
418
+ dx, dy = px - cx, py - cy
419
+ return cx + dx * math.cos(a) - dy * math.sin(a), cy + dx * math.sin(a) + dy * math.cos(a)
420
+ prof = [(-0.08, 0.05), (-0.22, 0.15), (-0.28, 0.4), (-0.32, 0.62), (-0.44, 0.74),
421
+ (0.44, 0.74), (0.32, 0.62), (0.28, 0.4), (0.22, 0.15), (0.08, 0.05)]
422
+ pts = [rot(cx + px * n, cy + py * n) for px, py in prof]
423
+ pygame.draw.polygon(s, (190, 130, 30), [(px + 1, py + 1) for px, py in pts])
424
+ pygame.draw.polygon(s, (255, 205, 70), pts)
425
+ pygame.draw.line(s, (255, 245, 190), rot(cx - n * 0.18, cy + n * 0.2), rot(cx - n * 0.24, cy + n * 0.55), max(1, n // 14))
426
+ pygame.draw.circle(s, (230, 160, 40), [int(v) for v in rot(cx + math.sin(a * 2) * n * 0.12, cy + n * 0.82)], max(2, n // 9))
427
+ pygame.draw.circle(s, (200, 200, 210), (int(cx), int(cy)), max(1, n // 16))
428
+ return f
429
+
430
+
431
+ def _icon_moon(t):
432
+ def f(s, x, y, n):
433
+ cx, cy, r = x + n // 2, y + n // 2, n * 2 // 5
434
+ pygame.draw.circle(s, (230, 235, 255), (cx, cy), r)
435
+ pygame.draw.circle(s, (8, 6, 12), (cx + r // 2, cy - r // 3), r)
436
+ for i, (sx, sy) in enumerate(((0.85, 0.15), (0.95, 0.55), (0.7, 0.85))):
437
+ tw = 0.5 + 0.5 * math.sin(t * 3 + i * 2)
438
+ v = int(120 + 135 * tw)
439
+ px, py = int(x + sx * n), int(y + sy * n)
440
+ s.fill((v, v, 255), (px, py, max(1, n // 20), max(1, n // 20)))
441
+ return f
442
+
443
+
444
+ def _clock(surf, cx, cy, r, t, ringing):
445
+ shake = math.sin(t * 55) * r * 0.07 if ringing else 0.0
446
+ tilt = math.sin(t * 55) * 0.08 if ringing else 0.0
447
+ cx += int(shake)
448
+ hit = math.sin(t * 55) > 0
449
+ for sgn in (-1, 1): # bells + hammer
450
+ bxy = (int(cx + sgn * r * 0.72 + math.sin(tilt) * r), int(cy - r * 0.82))
451
+ pygame.draw.circle(surf, (120, 120, 130), (bxy[0] + 1, bxy[1] + 1), int(r * 0.36))
452
+ pygame.draw.circle(surf, (225, 225, 235), bxy, int(r * 0.36))
453
+ pygame.draw.circle(surf, (255, 255, 255), (bxy[0] - r // 9, bxy[1] - r // 9), max(1, r // 12))
454
+ hx = cx + (r * 0.22 if hit else -r * 0.22) * (1 if ringing else 0)
455
+ pygame.draw.line(surf, (70, 70, 80), (cx, cy - r * 0.95), (hx, cy - r * 1.18), max(2, r // 9))
456
+ pygame.draw.circle(surf, (70, 70, 80), (int(hx), int(cy - r * 1.18)), max(2, r // 8))
457
+ for sgn in (-1, 1): # feet
458
+ pygame.draw.line(surf, (70, 70, 80), (cx + sgn * r * 0.55, cy + r * 0.8), (cx + sgn * r * 0.85, cy + r * 1.15), max(2, r // 7))
459
+ pygame.draw.circle(surf, (110, 20, 20), (cx + 2, cy + 2), r)
460
+ pygame.draw.circle(surf, (215, 50, 40), (cx, cy), r)
461
+ pygame.draw.circle(surf, (255, 140, 120), (cx - r // 3, cy - r // 2), max(1, r // 8))
462
+ pygame.draw.circle(surf, (250, 248, 236), (cx, cy), int(r * 0.8))
463
+ for i in range(12):
464
+ a = i * math.pi / 6
465
+ d = max(1, r // 14) * (2 if i % 3 == 0 else 1)
466
+ surf.fill((40, 40, 40), (int(cx + math.cos(a) * r * 0.68) - d // 2, int(cy + math.sin(a) * r * 0.68) - d // 2, d, d))
467
+ now = time.localtime()
468
+ for frac, ln, w in ((((now.tm_hour % 12) + now.tm_min / 60) / 12, 0.42, max(2, r // 9)),
469
+ (now.tm_min / 60, 0.62, max(1, r // 13)), (now.tm_sec / 60, 0.66, 1)):
470
+ ang = frac * math.tau - math.pi / 2
471
+ col = (200, 30, 30) if ln == 0.66 else (30, 30, 30)
472
+ pygame.draw.line(surf, col, (cx, cy), (cx + math.cos(ang) * r * ln, cy + math.sin(ang) * r * ln), w)
473
+ pygame.draw.circle(surf, (30, 30, 30), (cx, cy), max(2, r // 12))
474
+ if ringing: # "RRRING" vibration marks
475
+ for sgn in (-1, 1):
476
+ for k in range(3):
477
+ rr = r * (1.25 + k * 0.18)
478
+ a0 = -math.pi / 2 + sgn * 0.9
479
+ pygame.draw.arc(surf, (255, 200, 120), (cx - rr, cy - rr, rr * 2, rr * 2),
480
+ min(a0, a0 + sgn * 0.5), max(a0, a0 + sgn * 0.5), max(1, r // 16))
481
+
482
+ ALARMS = {c.name: c for c in (RedAlert, Warning, Doorbell, Timer, Calm)}
483
+
484
+
485
+ def parse(payload):
486
+ """'redalert' | 'redalert Washing machine done' | JSON {"type": .., "message": .., "duration": .., "sound": ..}"""
487
+ import json
488
+ p = (payload or '').strip()
489
+ if p.startswith('{'):
490
+ try:
491
+ d = json.loads(p)
492
+ return str(d.get('type') or d.get('alarm') or 'redalert'), str(d.get('message') or ''), d.get('duration'), \
493
+ d.get('sound', True) is not False
494
+ except ValueError:
495
+ pass
496
+ name, _, msg = p.partition(' ')
497
+ if name not in ALARMS:
498
+ name, msg = 'redalert', p
499
+ return name, msg, None, True