Generating a bytearray by choosing an exclusive set of parameters
Simplest would be to separate parsing of the bytes, words and dwords to their own respective method, this would simplify the main logic a lot

def pack_message2(DeviceID, Offset, dataLength=None,
                Byte0=None, Byte1=None, Byte2=None, Byte3=None,
                Word0=None, Word1=None, DWord0=None):

    Offset_low, Offset_high = parse_offset(Offset)

    if dataLength is not None and not 1 &lt;= dataLength &lt;= 4:
        raise ValueError('dataLength should be between 1 and 4')

    result = parse_dword(DWord0)
    if result == ():
#         print('no dwords')
        result = tuple(parse_words(Word0, Word1))  
        if result == ():
#             print('no words')
            result = parse_bytes(Byte0, Byte1, Byte2, Byte3)

    if dataLength is None:
        dataLength = len(result)

    result = pad_tuple_to(result, 4, 0) # if result should be padded with 0's'
#     print(result, dataLength)
    return bytearray([DeviceID, dataLength, Offset_low, Offset_high, *result])


Even if you wouldn't know what the implementation of each of these methods were, the code would be clear in what happens

offset

parsing the offset is pretty straightforward

def parse_offset(Offset):
    if Offset &gt;= 2**16:
        raise ValueError('Offset should be less than 2**16')
    return (Offset &amp; 0x00FF), ((Offset &amp; 0xFF00) &gt;&gt; 8)


Data

Instead of manually chaining the splitting the words in bytes, you can use that generator expression. This will also allow you to easily parse triple words or even quads without letting the code explode

def parse_dword(dword):
#     print('dword:', dword)
    if dword is None:
        return ()
    if not 0 &lt;= dword &lt;= 2**32:
        raise ValueError('dword should be between 0 and 2**32')
    return tuple((dword &amp; 0xFF * (2**(8*i))) &gt;&gt; (8 * i) for i in range(4))

def parse_words(*words):
#     print('words: ', words)
    for word in words:
        if word is None:
#             yield from (0, 0)
            break
        if not 0 &lt;= word  &lt;= 2**16:
            raise ValueError('word should be between 0 and 2**32')
        yield from ((word &amp; 0xFF * 2 ** (8*i)) &gt;&gt; (8 * i) for i in range(2))

from itertools import takewhile
def parse_bytes(*bytes_):
#     print('bytes: ', bytes_)
    return tuple(takewhile(lambda x: x is not None, bytes_))


These methods can be further generalized, but I'll leave that open as an exercise

padding the result

the simplest (but not purest) way to pad a tuple

def pad_tuple_to(tup, to, default=None):
    return tup + tuple(default for _ in range(to - len(tup)))


generalization

I was intrigued by the generalization, so I tried it myself

def generalized_parse_word(size, words):
#     print('words: ', words)
    if words is None:
        return 'empty'
    for word in words:
#         print('word: ', word)
        if word is None:
#             yield from (0, 0)
            break
        if not 0 &lt;= word  &lt;= 2 ** (8 * size):
            raise ValueError('word with %i should be between 0 and %i' % (size, 2 **(8*size)))
        yield from ((word &amp; 0xFF &lt;&lt; (8*i)) &gt;&gt; (8 * i) for i in range(size))

def pack_message3(DeviceID, Offset, dataLength=None,
                Byte0=None, Byte1=None, Byte2=None, Byte3=None,
                Word0=None, Word1=None, DWord0=None):

    Offset_low, Offset_high = parse_offset(Offset)

    if dataLength is not None and not 1 &lt;= dataLength &lt;= 4:
        raise ValueError('dataLength should be between 1 and 4')

    data = (
        (4, (DWord0,)),
        (2, (Word0, Word1)),
        (1, (Byte0, Byte1, Byte2, Byte3)),
    )

    for size, words in data:
        result = tuple(generalized_parse_word(size, words))
        if result != ():
            break

    if dataLength is None:
        dataLength = len(result)

    result = pad_tuple_to(result, 4, 0) # if result should be padded with 0's'
#     print(result, dataLength)
    return bytearray([DeviceID, dataLength, Offset_low, Offset_high, *result])


arguments

Instead of manually naming the different bytes or words, I would accept a collection as argument, something like this:

def pack_message4(DeviceID, Offset, dataLength=None,
                dwords=None, words=None, bytes_=None):
    ...
    data = ((4, dwords), (2, words), (1, bytes)) 
    ...


testing

inputs = [
    {'Word0': 0x1122, 'Word1': 0x3344},
    {'DWord0': 0x11223344},
    {'Byte0':34, 'Byte1':17, 'Byte2':68, 'Byte3': 51},
]
for i in inputs:
    ans0 = packMessage(DeviceID=3, Offset=220, **i)
    ans1 = pack_message3(DeviceID=3, Offset=220, **i)
    print(ans0 == ans1, ans0, ans1)



True bytearray(b'\x03\x04\xdc\x00"\x11D3') bytearray(b'\x03\x04\xdc\x00"\x11D3')
True bytearray(b'\x03\x04\xdc\x00D3"\x11') bytearray(b'\x03\x04\xdc\x00D3"\x11')
False bytearray(b'\x03\x03\xdc\x00"\x11D3') bytearray(b'\x03\x04\xdc\x00"\x11D3')



Could it be your method to calculate the dataLength in the case of Bytes is off by 1