pendify 0.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.
- pendify/__init__.py +1 -0
- pendify/__main__.py +265 -0
- pendify/alert.py +132 -0
- pendify/autostart.py +105 -0
- pendify/client.py +223 -0
- pendify/codes.py +38 -0
- pendify/config.py +223 -0
- pendify/icon.py +14 -0
- pendify/page.py +1136 -0
- pendify/pairing.py +192 -0
- pendify/pendify.ico +0 -0
- pendify/plate_almena.py +348 -0
- pendify/qr.py +433 -0
- pendify/runfile.py +246 -0
- pendify/watcher.py +348 -0
- pendify/worker.py +159 -0
- pendify-0.1.0.dist-info/METADATA +155 -0
- pendify-0.1.0.dist-info/RECORD +21 -0
- pendify-0.1.0.dist-info/WHEEL +5 -0
- pendify-0.1.0.dist-info/licenses/LICENSE +21 -0
- pendify-0.1.0.dist-info/top_level.txt +1 -0
pendify/qr.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""A QR encoder written here (ISO/IEC 18004), and its inline SVG.
|
|
2
|
+
|
|
3
|
+
Byte mode, error correction level M, versions 1 to 6, the mask chosen by the standard's four penalty
|
|
4
|
+
rules. The pairing address, 41 bytes, is version 3. Whether a phone's camera reads the result is proven
|
|
5
|
+
on the bench, never claimed from these tests alone (design section 4).
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from . import codes
|
|
10
|
+
|
|
11
|
+
PAIRING_ADDRESS = "https://pendiapp.com/link/pc#"
|
|
12
|
+
QUIET_ZONE = 4
|
|
13
|
+
# The drawn symbol's colours: ink on a white tile in both page themes, the finder rings in the app's violet.
|
|
14
|
+
_QR_INK, _QR_RING, _QR_TILE = "#1E1533", "#6D28D9", "#FFFFFF"
|
|
15
|
+
|
|
16
|
+
# Level M: version -> (EC codewords per block, blocks, data codewords per block). No short blocks up to 6.
|
|
17
|
+
_LEVEL_M = {1: (10, 1, 16), 2: (16, 1, 28), 3: (26, 1, 44), 4: (18, 2, 32), 5: (24, 2, 43), 6: (16, 4, 27)}
|
|
18
|
+
_ALIGNMENT = {1: (), 2: (6, 18), 3: (6, 22), 4: (6, 26), 5: (6, 30), 6: (6, 34)}
|
|
19
|
+
_LEVEL_M_BITS = 0b00
|
|
20
|
+
_FORMAT_GENERATOR = 0x537
|
|
21
|
+
_FORMAT_XOR = 0x5412
|
|
22
|
+
_MODE_BYTE = 0b0100
|
|
23
|
+
_PAD_BYTES = (0xEC, 0x11)
|
|
24
|
+
_FINDER_LIKE = (True, False, True, True, True, False, True)
|
|
25
|
+
|
|
26
|
+
# GF(256) over the polynomial 0x11D, as log and antilog tables.
|
|
27
|
+
_EXP = [0] * 512
|
|
28
|
+
_LOG = [0] * 256
|
|
29
|
+
_value = 1
|
|
30
|
+
for _i in range(255):
|
|
31
|
+
_EXP[_i], _LOG[_value] = _value, _i
|
|
32
|
+
_value <<= 1
|
|
33
|
+
if _value & 0x100:
|
|
34
|
+
_value ^= 0x11D
|
|
35
|
+
for _i in range(255, 512):
|
|
36
|
+
_EXP[_i] = _EXP[_i - 255]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class QrCode:
|
|
41
|
+
version: int
|
|
42
|
+
mask: int
|
|
43
|
+
modules: tuple # rows of booleans, True is dark
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def pairing_address(secret):
|
|
47
|
+
"""The one address the QR draws: the constant and the normalized secret."""
|
|
48
|
+
normal = codes.normalize(secret)
|
|
49
|
+
if normal is None:
|
|
50
|
+
raise ValueError("not a pairing value of twelve symbols")
|
|
51
|
+
return PAIRING_ADDRESS + normal
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _gf_mul(a, b):
|
|
55
|
+
return 0 if a == 0 or b == 0 else _EXP[_LOG[a] + _LOG[b]]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def reed_solomon_remainder(data, degree):
|
|
59
|
+
"""The EC codewords: data times x^degree modulo the generator whose roots are alpha^0 .. alpha^(degree-1)."""
|
|
60
|
+
generator = [1]
|
|
61
|
+
for power in range(degree):
|
|
62
|
+
product = generator + [0]
|
|
63
|
+
for j, coefficient in enumerate(generator):
|
|
64
|
+
product[j + 1] ^= _gf_mul(coefficient, _EXP[power])
|
|
65
|
+
generator = product
|
|
66
|
+
remainder = [0] * degree
|
|
67
|
+
for byte in data:
|
|
68
|
+
factor = byte ^ remainder[0]
|
|
69
|
+
remainder = remainder[1:] + [0]
|
|
70
|
+
for j in range(degree):
|
|
71
|
+
remainder[j] ^= _gf_mul(generator[j + 1], factor)
|
|
72
|
+
return remainder
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _choose_version(length):
|
|
76
|
+
for version, (_, blocks, per_block) in _LEVEL_M.items():
|
|
77
|
+
if 4 + 8 + 8 * length <= blocks * per_block * 8:
|
|
78
|
+
return version
|
|
79
|
+
raise ValueError("the data does not fit a version 1 to 6 symbol at level M")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _codewords(data, version):
|
|
83
|
+
ec, blocks, per_block = _LEVEL_M[version]
|
|
84
|
+
capacity = blocks * per_block * 8
|
|
85
|
+
bits = []
|
|
86
|
+
for value, width in [(_MODE_BYTE, 4), (len(data), 8)] + [(byte, 8) for byte in data]:
|
|
87
|
+
bits.extend((value >> shift) & 1 for shift in range(width - 1, -1, -1))
|
|
88
|
+
bits.extend([0] * min(4, capacity - len(bits)))
|
|
89
|
+
bits.extend([0] * (-len(bits) % 8))
|
|
90
|
+
words = [int("".join(map(str, bits[i:i + 8])), 2) for i in range(0, len(bits), 8)]
|
|
91
|
+
words += [_PAD_BYTES[i % 2] for i in range(blocks * per_block - len(words))]
|
|
92
|
+
data_blocks = [words[i * per_block:(i + 1) * per_block] for i in range(blocks)]
|
|
93
|
+
ec_blocks = [reed_solomon_remainder(block, ec) for block in data_blocks]
|
|
94
|
+
return ([block[i] for i in range(per_block) for block in data_blocks]
|
|
95
|
+
+ [block[i] for i in range(ec) for block in ec_blocks])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _draw_format(dark, function, mask):
|
|
99
|
+
size = len(dark)
|
|
100
|
+
data = (_LEVEL_M_BITS << 3) | mask
|
|
101
|
+
remainder = data
|
|
102
|
+
for _ in range(10):
|
|
103
|
+
remainder = (remainder << 1) ^ ((remainder >> 9) * _FORMAT_GENERATOR)
|
|
104
|
+
bits = ((data << 10) | remainder) ^ _FORMAT_XOR
|
|
105
|
+
places = ([(i, 8) for i in range(6)] + [(7, 8), (8, 8), (8, 7)] + [(8, 14 - i) for i in range(9, 15)],
|
|
106
|
+
[(8, size - 1 - i) for i in range(8)] + [(size - 15 + i, 8) for i in range(8, 15)])
|
|
107
|
+
for copy in places:
|
|
108
|
+
for i, (r, c) in enumerate(copy):
|
|
109
|
+
dark[r][c] = bool((bits >> i) & 1)
|
|
110
|
+
if function is not None:
|
|
111
|
+
function[r][c] = True
|
|
112
|
+
dark[size - 8][8] = True
|
|
113
|
+
if function is not None:
|
|
114
|
+
function[size - 8][8] = True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _function_grid(version):
|
|
118
|
+
size = 17 + 4 * version
|
|
119
|
+
dark = [[False] * size for _ in range(size)]
|
|
120
|
+
function = [[False] * size for _ in range(size)]
|
|
121
|
+
|
|
122
|
+
def put(r, c, value):
|
|
123
|
+
dark[r][c], function[r][c] = value, True
|
|
124
|
+
|
|
125
|
+
for i in range(size):
|
|
126
|
+
put(6, i, i % 2 == 0)
|
|
127
|
+
put(i, 6, i % 2 == 0)
|
|
128
|
+
for top, left in ((0, 0), (0, size - 7), (size - 7, 0)):
|
|
129
|
+
for dr in range(-1, 8):
|
|
130
|
+
for dc in range(-1, 8):
|
|
131
|
+
if 0 <= top + dr < size and 0 <= left + dc < size:
|
|
132
|
+
distance = max(abs(dr - 3), abs(dc - 3))
|
|
133
|
+
put(top + dr, left + dc, distance not in (2, 4))
|
|
134
|
+
centers = _ALIGNMENT[version]
|
|
135
|
+
for cr in centers:
|
|
136
|
+
for cc in centers:
|
|
137
|
+
if function[cr][cc]: # the three corners a finder already holds
|
|
138
|
+
continue
|
|
139
|
+
for dr in range(-2, 3):
|
|
140
|
+
for dc in range(-2, 3):
|
|
141
|
+
put(cr + dr, cc + dc, max(abs(dr), abs(dc)) != 1)
|
|
142
|
+
_draw_format(dark, function, 0) # reserves both format areas and the dark module
|
|
143
|
+
return dark, function
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _place(dark, function, words):
|
|
147
|
+
size, i, total = len(dark), 0, len(words) * 8
|
|
148
|
+
right = size - 1
|
|
149
|
+
while right >= 1:
|
|
150
|
+
if right == 6:
|
|
151
|
+
right = 5
|
|
152
|
+
upward = ((right + 1) & 2) == 0
|
|
153
|
+
for vertical in range(size):
|
|
154
|
+
r = size - 1 - vertical if upward else vertical
|
|
155
|
+
for c in (right, right - 1):
|
|
156
|
+
if not function[r][c] and i < total:
|
|
157
|
+
dark[r][c] = bool((words[i >> 3] >> (7 - (i & 7))) & 1)
|
|
158
|
+
i += 1
|
|
159
|
+
right -= 2
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _mask_bit(mask, r, c):
|
|
163
|
+
return (
|
|
164
|
+
(r + c) % 2 == 0, r % 2 == 0, c % 3 == 0, (r + c) % 3 == 0, (r // 2 + c // 3) % 2 == 0,
|
|
165
|
+
(r * c) % 2 + (r * c) % 3 == 0, ((r * c) % 2 + (r * c) % 3) % 2 == 0,
|
|
166
|
+
((r + c) % 2 + (r * c) % 3) % 2 == 0,
|
|
167
|
+
)[mask]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def encode(data, mask=None):
|
|
171
|
+
"""The symbol for `data` (bytes). The mask is the first with the lowest penalty unless one is forced."""
|
|
172
|
+
data = bytes(data)
|
|
173
|
+
if mask is not None and mask not in range(8):
|
|
174
|
+
raise ValueError("a mask is 0 to 7")
|
|
175
|
+
version = _choose_version(len(data))
|
|
176
|
+
dark, function = _function_grid(version)
|
|
177
|
+
_place(dark, function, _codewords(data, version))
|
|
178
|
+
best = None
|
|
179
|
+
for k in range(8) if mask is None else (mask,):
|
|
180
|
+
candidate = [[module ^ (not function[r][c] and _mask_bit(k, r, c)) for c, module in enumerate(row)]
|
|
181
|
+
for r, row in enumerate(dark)]
|
|
182
|
+
_draw_format(candidate, None, k)
|
|
183
|
+
score = penalty(candidate)
|
|
184
|
+
if best is None or score < best[0]:
|
|
185
|
+
best = (score, k, candidate)
|
|
186
|
+
return QrCode(version, best[1], tuple(tuple(row) for row in best[2]))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _runs_penalty(lines):
|
|
190
|
+
score = 0
|
|
191
|
+
for line in lines:
|
|
192
|
+
run, previous = 0, None
|
|
193
|
+
for module in list(line) + [None]:
|
|
194
|
+
if module == previous:
|
|
195
|
+
run += 1
|
|
196
|
+
continue
|
|
197
|
+
if run >= 5:
|
|
198
|
+
score += 3 + run - 5
|
|
199
|
+
run, previous = 1, module
|
|
200
|
+
return score
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _finder_like_penalty(lines):
|
|
204
|
+
count = 0
|
|
205
|
+
for line in lines:
|
|
206
|
+
line = list(line)
|
|
207
|
+
for x in range(len(line) - 6):
|
|
208
|
+
if tuple(line[x:x + 7]) == _FINDER_LIKE and (not any(line[max(x - 4, 0):x]) or not any(line[x + 7:x + 11])):
|
|
209
|
+
count += 1
|
|
210
|
+
return 40 * count
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def penalty(modules):
|
|
214
|
+
"""The four penalty rules (runs, 2 by 2 blocks, finder-like runs, dark balance); outside is light."""
|
|
215
|
+
rows = [[bool(module) for module in row] for row in modules]
|
|
216
|
+
columns = [list(column) for column in zip(*rows)]
|
|
217
|
+
blocks = sum(1 for r in range(len(rows) - 1) for c in range(len(rows[0]) - 1)
|
|
218
|
+
if rows[r][c] == rows[r][c + 1] == rows[r + 1][c] == rows[r + 1][c + 1])
|
|
219
|
+
total = sum(len(row) for row in rows)
|
|
220
|
+
dark = sum(sum(row) for row in rows)
|
|
221
|
+
return (_runs_penalty(rows) + _runs_penalty(columns) + 3 * blocks
|
|
222
|
+
+ _finder_like_penalty(rows) + _finder_like_penalty(columns)
|
|
223
|
+
+ 10 * (abs(dark * 2 - total) * 10 // total))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def svg(modules, labelledby=None):
|
|
227
|
+
"""An inline SVG of the symbol on a white tile that keeps a four-module quiet zone, one unit per module.
|
|
228
|
+
|
|
229
|
+
The grid is the encoder's; only how a module is painted changes. A dark data module is a 0.88 dot with
|
|
230
|
+
0.3 corners centred on its cell; each finder is a violet 7 by 7 ring, a white 5 by 5 separator and a 3 by 3
|
|
231
|
+
eye; each alignment pattern a 5 by 5 ring, its 3 by 3 gap and its centre. Every rounded corner is small
|
|
232
|
+
enough that each module's centre, where a reader samples, keeps the module's colour: a 7 by 7 ring keeps its
|
|
233
|
+
corner centre inside the arc only while its radius is under 0.5*sqrt(2)/(sqrt(2)-1), about 1.707.
|
|
234
|
+
"""
|
|
235
|
+
n, q = len(modules), QUIET_ZONE
|
|
236
|
+
side = n + 2 * q
|
|
237
|
+
finders = ((0, 0), (0, n - 7), (n - 7, 0))
|
|
238
|
+
|
|
239
|
+
def in_finder(r, c):
|
|
240
|
+
return any(top <= r < top + 7 and left <= c < left + 7 for top, left in finders)
|
|
241
|
+
|
|
242
|
+
centres = _ALIGNMENT[(n - 17) // 4]
|
|
243
|
+
alignments = [(r, c) for r in centres for c in centres if not in_finder(r, c)]
|
|
244
|
+
|
|
245
|
+
def drawn_apart(r, c):
|
|
246
|
+
return in_finder(r, c) or any(abs(r - a) <= 2 and abs(c - b) <= 2 for a, b in alignments)
|
|
247
|
+
|
|
248
|
+
dots = "".join(f'<rect x="{c + q}.06" y="{r + q}.06" width=".88" height=".88" rx=".3"/>'
|
|
249
|
+
for r, row in enumerate(modules) for c, module in enumerate(row)
|
|
250
|
+
if module and not drawn_apart(r, c))
|
|
251
|
+
label = f' aria-labelledby="{labelledby}"' if labelledby else ""
|
|
252
|
+
out = [f'<svg class="qr" viewBox="0 0 {side} {side}" role="img"{label}>',
|
|
253
|
+
f'<rect width="{side}" height="{side}" rx="1.5" fill="{_QR_TILE}"/>',
|
|
254
|
+
f'<g fill="{_QR_INK}">{dots}</g>']
|
|
255
|
+
for top, left in finders:
|
|
256
|
+
x, y = left + q, top + q
|
|
257
|
+
out.append(f'<rect x="{x}" y="{y}" width="7" height="7" rx="1.5" fill="{_QR_RING}"/>'
|
|
258
|
+
f'<rect x="{x + 1}" y="{y + 1}" width="5" height="5" rx="1.4" fill="{_QR_TILE}"/>'
|
|
259
|
+
f'<rect x="{x + 2}" y="{y + 2}" width="3" height="3" rx="1" fill="{_QR_INK}"/>')
|
|
260
|
+
for a, b in alignments:
|
|
261
|
+
x, y = b - 2 + q, a - 2 + q
|
|
262
|
+
out.append(f'<rect x="{x}" y="{y}" width="5" height="5" rx="1.5" fill="{_QR_INK}"/>'
|
|
263
|
+
f'<rect x="{x + 1}" y="{y + 1}" width="3" height="3" rx=".9" fill="{_QR_TILE}"/>'
|
|
264
|
+
f'<rect x="{x + 2}" y="{y + 2}" width="1" height="1" rx=".3" fill="{_QR_INK}"/>')
|
|
265
|
+
out.append("</svg>")
|
|
266
|
+
return "".join(out)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# The pairing QR as the scene Almena (design lane pcpg round 3, pcpg_scene_svg.py, ported as it is): the modules drawn
|
|
270
|
+
# as SVG polygons over one baked plate image (plate_almena.PLATE_DATA_URI, a 576 px WebP made for ELEVATION). The
|
|
271
|
+
# plate holds everything the scene has that no key changes: the shadow, the apron with the moat, battlements, banners,
|
|
272
|
+
# braziers, the two figures, the kerb, the air, and the code field lying flat with its picture, its timing, finders
|
|
273
|
+
# and alignment pattern and its quiet zone colours. scene_svg() draws what the key changes, through the renderer's own
|
|
274
|
+
# affine camera: every light module raised as a paver (its top shows the plate's own texture, lifted), the side faces
|
|
275
|
+
# of the pavers where a dark module lies in front, the dark modules' beds darkened, and one centre dot per data module
|
|
276
|
+
# in the module's colour (one projected octagon in defs, placed by <use> at each module's projected centre).
|
|
277
|
+
# The numbers are render_diorama.py's (Pendi-evidence Findings/05_qr/v4-2026-09-06) for ELEVATION and a version 3
|
|
278
|
+
# symbol, in plate units: 1 unit = 1 px of the 576 px plate = 1/2 CSS px of the page's 288 px QR box.
|
|
279
|
+
|
|
280
|
+
VIEW = 576 # plate width in px; render_diorama.py:43-45 renders OUT_PX 1600 from CANVAS 3200, the plate is that
|
|
281
|
+
# render resized to 576 (2x the page's .qr max-width of 288 px)
|
|
282
|
+
FIELD = 45 # render_diorama.py:554-555 field = span + 2 * RING, span = 29 modules + 2 * 4 quiet zone
|
|
283
|
+
RING = 4 # render_diorama.py:48 RING = APRON (3) + KERB (1), the tiles between the field edge and the code
|
|
284
|
+
QUIET = 4 # the quiet zone, qr.QUIET_ZONE; the renderer's code["border"] (themes_halftone.json halftone.border)
|
|
285
|
+
MODULES = 29 # the one symbol size the plate is drawn for: version 3, which every pairing address takes
|
|
286
|
+
ELEVATION = 75 # the camera's pitch in degrees, the "75" entry of pcpg-r3-camera.json (Pendi-evidence
|
|
287
|
+
# mockups/pcpg, 1135 bytes) and its plate. render_diorama.py:609 RULED_CAMERA is 45, but at 45
|
|
288
|
+
# zxing-cpp reads this composition at no box from 288 to 720 px (pcpg-r3-probe.txt), and at 60 the
|
|
289
|
+
# phone's engine (ZXing core 3.5.4, QRCodeReader, HybridBinarizer) reads 34 of 48 crops
|
|
290
|
+
# (SceneDecodeTest). 75 reads 48 of 48: 288 px on a 1x screen, and full and half size on a 2x one.
|
|
291
|
+
AX = 5.888 # render_diorama.py:123 x = cx + (u - v) * INV_SQRT2 * S, S from :113 (3200 * 0.92 / (45 * sqrt 2))
|
|
292
|
+
AY = 5.6874 # render_diorama.py:124 y term INV_SQRT2 * S * sin(ELEVATION), per unit of u + v
|
|
293
|
+
AH = 2.1552 # render_diorama.py:125 and :127-128 rise per unit of height, S * cos(ELEVATION)
|
|
294
|
+
X0 = 288.0 # render_diorama.py:114 cx, the canvas centre
|
|
295
|
+
Y0 = 37.0467 # render_diorama.py:115-118 cy after the centring pass, minus FIELD * AY (so y = Y0 + (u + v) * AY)
|
|
296
|
+
RELIEF = 0.23 # render_diorama.py:50 RELIEF = PAVER_H (:52), the height of a light module; a dark one sits at
|
|
297
|
+
# SOIL_H 0 (:51)
|
|
298
|
+
KERB_H = 0.62 # render_diorama.py:54, the kerb's top; its near inner edges hide the field's near rows
|
|
299
|
+
PAVER_INSET = 0.0 # render_diorama.py:239-240 a paver's top is its whole tile; the renderer draws no inset or gap
|
|
300
|
+
LIP = 0.38 # render_diorama.py:57 share of a side face drawn as its lit top edge (_face, :223-231)
|
|
301
|
+
LIP_LIGHT = 1.30 # render_diorama.py:229 the lip is the wall colour at 1.30
|
|
302
|
+
WALL_LIT = 0.70 # render_diorama.py:65 and :217 wall of a light paver, used on its +v face (:241, side 0)
|
|
303
|
+
WALL_DARK = 0.52 # render_diorama.py:66 and :217 the other wall, used on its +u face (:241, side 1)
|
|
304
|
+
BED_DARK = 0.86 # render_diorama.py:269 a dark module's bed is its texture at 0.86: here black at 0.14 opacity
|
|
305
|
+
DOT = 0.42 # PendiQrStudio themes_halftone.json halftone.dot, halftone.py:229: centre dot diameter in modules
|
|
306
|
+
GROUND = "#F6F3FA" # the scene's code palette after halftone.palette_of (pcpg-r3-camera.json): pavers, light dots
|
|
307
|
+
INK = "#3A1D78" # the ink, dark dots (darkened with their bed, as the renderer's texture is)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _shade(hex_colour, factor):
|
|
311
|
+
"""render_diorama.py:135-136 shade(), on a #RRGGBB string."""
|
|
312
|
+
rgb = [int(hex_colour[i:i + 2], 16) for i in (1, 3, 5)]
|
|
313
|
+
return "#" + "".join("%02X" % max(0, min(255, int(round(c * factor)))) for c in rgb)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
FACE_V = _shade(GROUND, WALL_LIT)
|
|
317
|
+
FACE_U = _shade(GROUND, WALL_DARK)
|
|
318
|
+
FACE_V_LIP = _shade(FACE_V, LIP_LIGHT)
|
|
319
|
+
FACE_U_LIP = _shade(FACE_U, LIP_LIGHT)
|
|
320
|
+
_OCTAGON = ((0.21, 0.087), (0.087, 0.21), (-0.087, 0.21), (-0.21, 0.087), (-0.21, -0.087), (-0.087, -0.21),
|
|
321
|
+
(0.087, -0.21), (0.21, -0.087)) # a circle of diameter DOT (0.42 modules) as 8 corners, in module units
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _pt(u, v, h):
|
|
325
|
+
"""render_diorama.py:120-125 Camera.project, u the field column, v the field row, h the height."""
|
|
326
|
+
return X0 + (u - v) * AX, Y0 + (u + v) * AY - h * AH
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _poly(points):
|
|
330
|
+
"""A closed polygon, first corner absolute, the rest relative, all on the 0.1 unit grid (no drift)."""
|
|
331
|
+
q = [(round(x * 10), round(y * 10)) for x, y in points]
|
|
332
|
+
out = "M%s %s" % (_num(q[0][0]), _num(q[0][1]))
|
|
333
|
+
for (x0, y0), (x1, y1) in zip(q, q[1:]):
|
|
334
|
+
out += "l%s %s" % (_num(x1 - x0), _num(y1 - y0))
|
|
335
|
+
return out + "z"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _num(tenths):
|
|
339
|
+
"""An integer count of tenths as the shortest SVG number: 12 -> 1.2, -3 -> -.3, 40 -> 4."""
|
|
340
|
+
sign, t = ("-" if tenths < 0 else ""), abs(tenths)
|
|
341
|
+
whole, frac = divmod(t, 10)
|
|
342
|
+
return sign + ((str(whole) if whole else "") + ("." + str(frac) if frac else "") or "0")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _function(r, c, n):
|
|
346
|
+
"""Finders with their separators, timing, the version 3 alignment pattern: drawn flat in the plate."""
|
|
347
|
+
if (r < 8 and c < 8) or (r < 8 and c >= n - 8) or (r >= n - 8 and c < 8):
|
|
348
|
+
return True
|
|
349
|
+
if r == 6 or c == 6:
|
|
350
|
+
return True
|
|
351
|
+
return abs(r - 22) <= 2 and abs(c - 22) <= 2
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _runs(cells):
|
|
355
|
+
"""Sorted integers to (first, last) runs."""
|
|
356
|
+
out = []
|
|
357
|
+
for x in cells:
|
|
358
|
+
if out and x == out[-1][1] + 1:
|
|
359
|
+
out[-1][1] = x
|
|
360
|
+
else:
|
|
361
|
+
out.append([x, x])
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def scene_svg(modules, plate_data_uri, labelledby=None, uid="q"):
|
|
366
|
+
"""The pairing QR as the scene: the plate image under a group of polygons. None when the symbol is not version 3,
|
|
367
|
+
and the caller draws svg() instead.
|
|
368
|
+
|
|
369
|
+
Drawing order, the renderer's painter order folded into layers (draw_floor, render_diorama.py:234-274): the ink
|
|
370
|
+
centre dots of the dark data modules on the floor, the symbol's floor darkened to BED_DARK, the side faces of the
|
|
371
|
+
light modules that have a dark module in front, then the light modules' tops (the plate lifted by the relief,
|
|
372
|
+
clipped to the light modules) and their ground dots. A light module's floor is always covered by its top,
|
|
373
|
+
its faces or its near neighbours' tops, so only the dark floors stay darkened and dotted, as in the renderer. The
|
|
374
|
+
near kerb clips it all."""
|
|
375
|
+
n = len(modules)
|
|
376
|
+
if n != MODULES:
|
|
377
|
+
return None
|
|
378
|
+
lo, hi = RING, RING + n + 2 * QUIET # the code field, quiet zone included, in field tiles
|
|
379
|
+
s0, s1 = RING + QUIET, RING + QUIET + n # the symbol
|
|
380
|
+
|
|
381
|
+
def dark(u, v):
|
|
382
|
+
r, c = v - s0, u - s0
|
|
383
|
+
return 0 <= r < n and 0 <= c < n and bool(modules[r][c])
|
|
384
|
+
|
|
385
|
+
def area(c0, r0, c1, r1, h): # a block of whole modules, symbol coordinates, at height h
|
|
386
|
+
return _poly([_pt(s0 + c0, s0 + r0, h), _pt(s0 + c1, s0 + r0, h), _pt(s0 + c1, s0 + r1, h),
|
|
387
|
+
_pt(s0 + c0, s0 + r1, h)])
|
|
388
|
+
|
|
389
|
+
def data_region(h): # the symbol minus finders, separators, timing and alignment
|
|
390
|
+
holes = [(0, 0, 8, 8), (n - 8, 0, n, 8), (0, n - 8, 8, n), (20, 20, 25, 25), (8, 6, n - 8, 7), (6, 8, 7, n - 8)]
|
|
391
|
+
return area(0, 0, n, n, h) + "".join(area(*box, h) for box in holes)
|
|
392
|
+
|
|
393
|
+
tops, face_u, face_v, lips_u, lips_v = [], [], [], [], []
|
|
394
|
+
drop = RELIEF * AH
|
|
395
|
+
cut = drop * LIP
|
|
396
|
+
for v in range(lo, hi):
|
|
397
|
+
for u0, u1 in _runs([u for u in range(lo, hi) if not dark(u, v)]):
|
|
398
|
+
tops.append(_poly([_pt(u0 + PAVER_INSET, v, RELIEF), _pt(u1 + 1, v, RELIEF), _pt(u1 + 1, v + 1, RELIEF),
|
|
399
|
+
_pt(u0, v + 1, RELIEF)]))
|
|
400
|
+
for u0, u1 in _runs([u for u in range(lo, hi) if not dark(u, v) and dark(u, v + 1)]):
|
|
401
|
+
p0, p1 = _pt(u0, v + 1, RELIEF), _pt(u1 + 1, v + 1, RELIEF)
|
|
402
|
+
lips_v.append(_poly([p0, p1, (p1[0], p1[1] + cut), (p0[0], p0[1] + cut)]))
|
|
403
|
+
face_v.append(_poly([(p0[0], p0[1] + cut), (p1[0], p1[1] + cut), (p1[0], p1[1] + drop),
|
|
404
|
+
(p0[0], p0[1] + drop)]))
|
|
405
|
+
for u in range(lo, hi):
|
|
406
|
+
for v0, v1 in _runs([v for v in range(lo, hi) if not dark(u, v) and dark(u + 1, v)]):
|
|
407
|
+
p0, p1 = _pt(u + 1, v0, RELIEF), _pt(u + 1, v1 + 1, RELIEF)
|
|
408
|
+
lips_u.append(_poly([p0, p1, (p1[0], p1[1] + cut), (p0[0], p0[1] + cut)]))
|
|
409
|
+
face_u.append(_poly([(p0[0], p0[1] + cut), (p1[0], p1[1] + cut), (p1[0], p1[1] + drop),
|
|
410
|
+
(p0[0], p0[1] + drop)]))
|
|
411
|
+
edge = hi # the near kerb's inner edges, u = edge and v = edge, at KERB_H
|
|
412
|
+
a, b, c_ = _pt(edge, lo - 40, KERB_H), _pt(edge, edge, KERB_H), _pt(lo - 40, edge, KERB_H)
|
|
413
|
+
kerb = _poly([a, b, c_, (c_[0], c_[1] - 4 * VIEW), (a[0], a[1] - 4 * VIEW)])
|
|
414
|
+
label = f' aria-labelledby="{labelledby}"' if labelledby else ""
|
|
415
|
+
faces = "".join(f'<path fill="{fill}" d="{"".join(d)}"/>' for fill, d in
|
|
416
|
+
((FACE_V_LIP, lips_v), (FACE_V, face_v), (FACE_U_LIP, lips_u), (FACE_U, face_u)) if d)
|
|
417
|
+
|
|
418
|
+
dot = _poly([((du - dv) * AX, (du + dv) * AY) for du, dv in _OCTAGON]) # one dot, centred on 0 0
|
|
419
|
+
dots = {True: [], False: []}
|
|
420
|
+
for r in range(n):
|
|
421
|
+
for c in range(n):
|
|
422
|
+
if not _function(r, c, n):
|
|
423
|
+
x, y = _pt(s0 + c + 0.5, s0 + r + 0.5, 0.0 if modules[r][c] else RELIEF)
|
|
424
|
+
dots[bool(modules[r][c])].append(f'<use href="#{uid}d" x="{x:.1f}" y="{y:.1f}"/>')
|
|
425
|
+
return (f'<svg class="qr" viewBox="0 0 {VIEW} {VIEW}" role="img"{label}>'
|
|
426
|
+
f'<defs><image id="{uid}p" width="{VIEW}" height="{VIEW}" href="{plate_data_uri}"/>'
|
|
427
|
+
f'<clipPath id="{uid}t"><path d="{"".join(tops)}"/></clipPath>'
|
|
428
|
+
f'<clipPath id="{uid}k"><path d="{kerb}"/></clipPath><path id="{uid}d" d="{dot}"/></defs>'
|
|
429
|
+
f'<use href="#{uid}p"/><g clip-path="url(#{uid}k)">'
|
|
430
|
+
f'<g fill="{INK}">{"".join(dots[True])}</g>'
|
|
431
|
+
f'<path fill-opacity="{1 - BED_DARK:.2f}" d="{area(0, 0, n, n, 0.0)}"/>{faces}'
|
|
432
|
+
f'<g clip-path="url(#{uid}t)"><use href="#{uid}p" y="{-drop:.2f}"/></g>'
|
|
433
|
+
f'<g fill="{GROUND}">{"".join(dots[False])}</g></g></svg>')
|
pendify/runfile.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""One instance per config folder: the running process holds a run file with its pid and its page's port.
|
|
2
|
+
|
|
3
|
+
A start creates the file exclusively. When the file is already there and names a live process other than
|
|
4
|
+
this one, the start stops; a file whose pid is gone, or that names no pid, is taken over. A live pid is
|
|
5
|
+
asked of the system through ctypes on Windows (os.kill there would end the process) and os.kill elsewhere.
|
|
6
|
+
A refused remove or create is told apart before it is read as another start that won the race: a config
|
|
7
|
+
folder that takes no new file, or a run file still there with no live holder when the re-read window ends,
|
|
8
|
+
stops this start; only a live holder's record read within the window is that other start.
|
|
9
|
+
|
|
10
|
+
At its stop the holder marks its record closing, beside its pid and port, until it removes the file: a start that
|
|
11
|
+
meets a closing holder waits for the file to go instead of opening a page that is closing. A record with no mark,
|
|
12
|
+
as an older copy writes it, reads as not closing.
|
|
13
|
+
"""
|
|
14
|
+
import errno
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import secrets
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
FILE_NAME = "run.json"
|
|
23
|
+
# How long a start re-reads the other start's record: the winner of a race writes its pid right after its
|
|
24
|
+
# create and its port once its page listens.
|
|
25
|
+
REREAD_SECONDS = 1.0
|
|
26
|
+
REREAD_STEP = 0.05
|
|
27
|
+
# A start re-reading the file holds it for about a millisecond, and Windows refuses this holder's remove or replace
|
|
28
|
+
# meanwhile: each is tried REFUSED_TRIES times, REFUSED_STEP apart, 40 ms of waits in all.
|
|
29
|
+
REFUSED_TRIES = 5
|
|
30
|
+
REFUSED_STEP = 0.01
|
|
31
|
+
_STILL_ACTIVE = 259
|
|
32
|
+
_ERROR_ACCESS_DENIED = 5
|
|
33
|
+
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FolderNotWritable(PermissionError):
|
|
37
|
+
"""The config folder takes no new file, so no start can hold its run file there; `filename` names it."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def pid_alive(pid):
|
|
41
|
+
"""True when `pid` names a running process."""
|
|
42
|
+
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
|
|
43
|
+
return False
|
|
44
|
+
if os.name == "nt":
|
|
45
|
+
return _windows_alive(pid)
|
|
46
|
+
try:
|
|
47
|
+
os.kill(pid, 0) # signal 0 only asks
|
|
48
|
+
except ProcessLookupError:
|
|
49
|
+
return False
|
|
50
|
+
except PermissionError:
|
|
51
|
+
return True
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _windows_alive(pid):
|
|
56
|
+
import ctypes
|
|
57
|
+
from ctypes import wintypes
|
|
58
|
+
|
|
59
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
60
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
61
|
+
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
62
|
+
kernel32.GetExitCodeProcess.argtypes = (wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD))
|
|
63
|
+
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
64
|
+
handle = kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
65
|
+
if not handle:
|
|
66
|
+
return ctypes.get_last_error() == _ERROR_ACCESS_DENIED # a process this user may not open still runs
|
|
67
|
+
try:
|
|
68
|
+
code = wintypes.DWORD()
|
|
69
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
70
|
+
return True
|
|
71
|
+
return code.value == _STILL_ACTIVE
|
|
72
|
+
finally:
|
|
73
|
+
kernel32.CloseHandle(handle)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RunFile:
|
|
77
|
+
def __init__(self, folder, pid=None, alive=pid_alive, clock=time.monotonic, sleep=time.sleep):
|
|
78
|
+
self.path = Path(folder) / FILE_NAME
|
|
79
|
+
self._pid = os.getpid() if pid is None else pid
|
|
80
|
+
self._alive = alive
|
|
81
|
+
self._clock = clock
|
|
82
|
+
self._sleep = sleep
|
|
83
|
+
self._held = False
|
|
84
|
+
self._port = None
|
|
85
|
+
|
|
86
|
+
def claim(self):
|
|
87
|
+
"""None when this process now holds the file; the live holder's record, {pid, port, closing}, when
|
|
88
|
+
another does. A refused remove or create (PermissionError on Windows: the file held open, or pending its
|
|
89
|
+
removal, by another start) is that other start only when its live record shows within
|
|
90
|
+
REREAD_SECONDS. FolderNotWritable when the config folder takes no new file; OSError when the file
|
|
91
|
+
can neither be created nor taken over."""
|
|
92
|
+
try:
|
|
93
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
except PermissionError:
|
|
95
|
+
raise FolderNotWritable(errno.EACCES, "the config folder cannot be made", str(self.path.parent)) from None
|
|
96
|
+
except FileExistsError: # what Windows raises when a regular file stands where the folder or a parent goes
|
|
97
|
+
raise FolderNotWritable(errno.EACCES, "the config folder cannot be made", str(self.path.parent)) from None
|
|
98
|
+
for _ in range(3):
|
|
99
|
+
try:
|
|
100
|
+
handle = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
101
|
+
except FileExistsError:
|
|
102
|
+
holder = self._live_holder()
|
|
103
|
+
if holder is not None:
|
|
104
|
+
return holder
|
|
105
|
+
try:
|
|
106
|
+
os.remove(self.path) # a holder that is gone, or a file that names none: taken over
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
pass
|
|
109
|
+
except PermissionError: # held open by another start, or a file this user cannot remove
|
|
110
|
+
holder = self._refused()
|
|
111
|
+
if holder is not None:
|
|
112
|
+
return holder
|
|
113
|
+
continue
|
|
114
|
+
except PermissionError: # pending its removal under another handle, or a folder that takes no file
|
|
115
|
+
holder = self._refused()
|
|
116
|
+
if holder is not None:
|
|
117
|
+
return holder
|
|
118
|
+
continue
|
|
119
|
+
with os.fdopen(handle, "w", encoding="utf-8") as out:
|
|
120
|
+
json.dump({"pid": self._pid, "port": None}, out)
|
|
121
|
+
self._held = True
|
|
122
|
+
return None
|
|
123
|
+
raise FileExistsError("the run file came back after every take-over")
|
|
124
|
+
|
|
125
|
+
def _refused(self):
|
|
126
|
+
"""A refused remove or create, told apart: the live holder's record when it shows within the window;
|
|
127
|
+
None when the file is gone by then, so the claim tries again. A folder in the file's place, a config
|
|
128
|
+
folder that takes no new file, or a file still there naming no live holder: this start cannot go on."""
|
|
129
|
+
if self.path.is_dir():
|
|
130
|
+
raise IsADirectoryError(errno.EISDIR, "the run file is a folder", str(self.path))
|
|
131
|
+
self._probe_folder()
|
|
132
|
+
deadline = self._clock() + REREAD_SECONDS
|
|
133
|
+
while True:
|
|
134
|
+
holder = self._live_holder()
|
|
135
|
+
if holder is not None:
|
|
136
|
+
return holder
|
|
137
|
+
if not os.path.lexists(self.path):
|
|
138
|
+
return None
|
|
139
|
+
if self._clock() >= deadline:
|
|
140
|
+
raise PermissionError(errno.EACCES, "the run file names no live holder and cannot be replaced",
|
|
141
|
+
str(self.path))
|
|
142
|
+
self._sleep(REREAD_STEP)
|
|
143
|
+
|
|
144
|
+
def _probe_folder(self):
|
|
145
|
+
"""FolderNotWritable when the config folder takes no new file: a probe name, made and removed at once."""
|
|
146
|
+
probe = self.path.parent / f".probe-{self._pid}-{secrets.token_hex(4)}.tmp"
|
|
147
|
+
try:
|
|
148
|
+
handle = os.open(probe, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
149
|
+
except PermissionError:
|
|
150
|
+
raise FolderNotWritable(errno.EACCES, "the config folder takes no new file",
|
|
151
|
+
str(self.path.parent)) from None
|
|
152
|
+
os.close(handle)
|
|
153
|
+
os.remove(probe)
|
|
154
|
+
|
|
155
|
+
def holder_port(self, holder):
|
|
156
|
+
"""The port of `holder`, a live holder claim() returned, re-read from its record for up to
|
|
157
|
+
REREAD_SECONDS; None when it has published none by then."""
|
|
158
|
+
deadline = self._clock() + REREAD_SECONDS
|
|
159
|
+
while True:
|
|
160
|
+
record = self._read()
|
|
161
|
+
if record is not None and record["pid"] == holder["pid"] and record["port"] is not None:
|
|
162
|
+
return record["port"]
|
|
163
|
+
if self._clock() >= deadline:
|
|
164
|
+
return None
|
|
165
|
+
self._sleep(REREAD_STEP)
|
|
166
|
+
|
|
167
|
+
def wait_released(self, holder, seconds):
|
|
168
|
+
"""True once the run file no longer names `holder`, a live holder claim() returned closing, as a live
|
|
169
|
+
process: the file gone, its process gone, or another start's record in its place; re-read every
|
|
170
|
+
REREAD_STEP. False when it still does after `seconds`."""
|
|
171
|
+
deadline = self._clock() + seconds
|
|
172
|
+
while True:
|
|
173
|
+
record = self._live_holder()
|
|
174
|
+
if record is None or record["pid"] != holder["pid"]:
|
|
175
|
+
return True
|
|
176
|
+
if self._clock() >= deadline:
|
|
177
|
+
return False
|
|
178
|
+
self._sleep(REREAD_STEP)
|
|
179
|
+
|
|
180
|
+
def publish(self, port):
|
|
181
|
+
"""This process's pid and its page's port, written over the claim in one move; PermissionError when the
|
|
182
|
+
move is still refused after its retries."""
|
|
183
|
+
self._write({"pid": self._pid, "port": port})
|
|
184
|
+
self._port = port
|
|
185
|
+
|
|
186
|
+
def mark_closing(self):
|
|
187
|
+
"""This process's record marked closing, in the same one move: a start that meets it waits for the
|
|
188
|
+
file to go. Nothing when this process does not hold the file."""
|
|
189
|
+
if self._held:
|
|
190
|
+
self._write({"pid": self._pid, "port": self._port, "closing": True})
|
|
191
|
+
|
|
192
|
+
def _write(self, record):
|
|
193
|
+
handle, temp = tempfile.mkstemp(dir=self.path.parent, prefix=".run-", suffix=".tmp")
|
|
194
|
+
try:
|
|
195
|
+
with os.fdopen(handle, "w", encoding="utf-8") as out:
|
|
196
|
+
json.dump(record, out)
|
|
197
|
+
self._retried(os.replace, temp, self.path)
|
|
198
|
+
except BaseException:
|
|
199
|
+
if os.path.exists(temp):
|
|
200
|
+
os.remove(temp)
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
def release(self):
|
|
204
|
+
"""Removes the file when this process holds it and it still names this process; a remove still refused
|
|
205
|
+
after its retries leaves the file and raises nothing."""
|
|
206
|
+
if not self._held:
|
|
207
|
+
return
|
|
208
|
+
self._held = False
|
|
209
|
+
holder = self._read()
|
|
210
|
+
if holder is not None and holder["pid"] == self._pid:
|
|
211
|
+
try:
|
|
212
|
+
self._retried(os.remove, self.path)
|
|
213
|
+
except FileNotFoundError:
|
|
214
|
+
pass
|
|
215
|
+
except PermissionError:
|
|
216
|
+
pass # refused to the end: the file stays, and a start waiting on it takes it over once this pid dies
|
|
217
|
+
|
|
218
|
+
def _retried(self, move, *args):
|
|
219
|
+
"""`move(*args)`, tried again REFUSED_STEP later while a reader makes the system refuse it, REFUSED_TRIES
|
|
220
|
+
times in all; the last refusal raises."""
|
|
221
|
+
for attempt in range(1, REFUSED_TRIES + 1):
|
|
222
|
+
try:
|
|
223
|
+
return move(*args)
|
|
224
|
+
except PermissionError:
|
|
225
|
+
if attempt == REFUSED_TRIES:
|
|
226
|
+
raise
|
|
227
|
+
self._sleep(REFUSED_STEP)
|
|
228
|
+
|
|
229
|
+
def _live_holder(self):
|
|
230
|
+
"""The record of a live process other than this one, or None."""
|
|
231
|
+
holder = self._read()
|
|
232
|
+
if holder is not None and holder["pid"] != self._pid and self._alive(holder["pid"]):
|
|
233
|
+
return holder
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
def _read(self):
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
239
|
+
except (OSError, ValueError):
|
|
240
|
+
return None
|
|
241
|
+
pid = data.get("pid") if isinstance(data, dict) else None
|
|
242
|
+
if isinstance(pid, bool) or not isinstance(pid, int):
|
|
243
|
+
return None
|
|
244
|
+
port = data.get("port")
|
|
245
|
+
valid = isinstance(port, int) and not isinstance(port, bool) and 0 < port < 65536
|
|
246
|
+
return {"pid": pid, "port": port if valid else None, "closing": data.get("closing") is True}
|